From 09347616d4e2adf7faa4a5a875fc86e157ccd2c9 Mon Sep 17 00:00:00 2001 From: Joel Tony Date: Mon, 19 Oct 2026 19:11:37 +0530 Subject: [PATCH 1/4] zenity: Keep file names that aren't UTF-8 zenity 4 prints the bytes of each selected path, and rfd decoded them as UTF-8, so a file name that isn't UTF-8 made the dialog return `None`. Keep the output as bytes and build the paths from them. --- CHANGELOG.md | 1 + src/backend/linux/zenity.rs | 47 +++++++++++++++++++++++-------------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8df3300..cdc27885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Fix macOS file dialogs ignoring `set_directory` when `set_file_name` is also used. - Add `FileDialog::set_show_hidden_files` and `AsyncFileDialog::set_show_hidden_files` to control hidden file visibility. Supported on macOS, Windows, and Linux (GTK3). - Honor `FileDialog::set_directory` and `AsyncFileDialog::set_directory` when using the zenity fallback backend on Linux. +- Fix the zenity fallback returning `None` for a file name that isn't valid UTF-8. zenity's output is now kept as bytes. - Fix `liblary` typo in docs - Change wasm title to sanitize text contents via `title_el.set_text_content()` rather than `title_el.set_inner_html()`. - Set wasm dialog styles via `set_text_content()` rather than `set_inner_html()`, so rfd no longer uses `innerHTML`. diff --git a/src/backend/linux/zenity.rs b/src/backend/linux/zenity.rs index 4004055e..d5f9d569 100644 --- a/src/backend/linux/zenity.rs +++ b/src/backend/linux/zenity.rs @@ -1,4 +1,7 @@ -use std::{error::Error, fmt::Display, path::PathBuf, process::Command}; +use std::{ + error::Error, ffi::OsStr, fmt::Display, os::unix::ffi::OsStrExt, path::PathBuf, + process::Command, +}; use crate::{ message_dialog::{MessageButtons, MessageLevel}, @@ -8,7 +11,6 @@ use crate::{ #[derive(Debug)] pub enum ZenityError { Io(std::io::Error), - FromUtf8Error(std::string::FromUtf8Error), } impl Error for ZenityError {} @@ -17,7 +19,6 @@ impl Display for ZenityError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ZenityError::Io(io) => write!(f, "{io}"), - ZenityError::FromUtf8Error(err) => err.fmt(f), } } } @@ -28,12 +29,6 @@ impl From for ZenityError { } } -impl From for ZenityError { - fn from(value: std::string::FromUtf8Error) -> Self { - Self::FromUtf8Error(value) - } -} - pub type ZenityResult = Result; fn command() -> Command { @@ -73,7 +68,7 @@ fn file_dialog_command(dialog: &FileDialog) -> Command { command } -async fn run(mut command: Command) -> ZenityResult> { +async fn run(mut command: Command) -> ZenityResult>> { let res = { let (tx, rx) = crate::oneshot::channel(); std::thread::spawn(move || { @@ -81,14 +76,19 @@ async fn run(mut command: Command) -> ZenityResult> { }); rx.await.map_err(std::io::Error::other)?? }; - let buffer = String::from_utf8(res.stdout)?; + let buffer = res.stdout; Ok((res.status.success() || !buffer.is_empty()).then_some(buffer)) } /// Strip the single newline zenity prints after its output -fn strip_newline(buffer: &str) -> &str { - buffer.strip_suffix('\n').unwrap_or(buffer) +fn strip_newline(buffer: &[u8]) -> &[u8] { + buffer.strip_suffix(b"\n").unwrap_or(buffer) +} + +/// Keep the bytes zenity prints as they are, as a file name doesn't have to be UTF-8 +fn to_path(bytes: &[u8]) -> PathBuf { + PathBuf::from(OsStr::from_bytes(bytes)) } pub async fn pick_file(dialog: &FileDialog) -> ZenityResult> { @@ -99,7 +99,7 @@ pub async fn pick_file(dialog: &FileDialog) -> ZenityResult> { .await? .as_deref() .map(strip_newline) - .map(PathBuf::from)) + .map(to_path)) } pub async fn pick_files(dialog: &FileDialog) -> ZenityResult> { @@ -110,7 +110,7 @@ pub async fn pick_files(dialog: &FileDialog) -> ZenityResult> { .await? .as_deref() .map(strip_newline) - .map(|s| s.split('\n').map(PathBuf::from).collect()) + .map(|s| s.split(|&byte| byte == b'\n').map(to_path).collect()) .unwrap_or_default()) } @@ -122,7 +122,7 @@ pub async fn pick_folder(dialog: &FileDialog) -> ZenityResult> { .await? .as_deref() .map(strip_newline) - .map(PathBuf::from)) + .map(to_path)) } pub async fn pick_folders(dialog: &FileDialog) -> ZenityResult> { @@ -138,7 +138,7 @@ pub async fn pick_folders(dialog: &FileDialog) -> ZenityResult> { .await? .as_deref() .map(strip_newline) - .map(|s| s.split('\n').map(PathBuf::from).collect()) + .map(|s| s.split(|&byte| byte == b'\n').map(to_path).collect()) .unwrap_or_default()) } @@ -150,7 +150,7 @@ pub async fn save_file(dialog: &FileDialog) -> ZenityResult> { .await? .as_deref() .map(strip_newline) - .map(PathBuf::from)) + .map(to_path)) } pub async fn message( @@ -245,6 +245,8 @@ pub async fn question( #[cfg(test)] mod tests { + use std::{ffi::OsStr, os::unix::ffi::OsStrExt, path::PathBuf}; + use crate::FileDialog; #[test] @@ -317,4 +319,13 @@ mod tests { let path = pollster::block_on(super::save_file(&FileDialog::default())).unwrap(); dbg!(path); } + + #[test] + fn output_path() { + assert_eq!( + super::to_path(super::strip_newline(b"/home/user/caf\xe9.txt\n")), + PathBuf::from(OsStr::from_bytes(b"/home/user/caf\xe9.txt")) + ); + assert_eq!(super::strip_newline(b"x\n\n"), b"x\n"); + } } From e8ae2016674e88360b7575be8a5af32b6e720b6c Mon Sep 17 00:00:00 2001 From: Joel Tony Date: Mon, 19 Oct 2026 19:11:46 +0530 Subject: [PATCH 2/4] zenity: Return None when pick_files or pick_folders is cancelled A cancelled zenity prints nothing and exits with 1. `pick_files` and `pick_folders` turned that into an empty list, which the portal backend returned as `Some(vec![])`. Return `None` instead, as the portal branch does since #323. --- CHANGELOG.md | 1 + src/backend/linux/zenity.rs | 10 ++++------ src/backend/xdg_desktop_portal.rs | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc27885..24d54df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Add `FileDialog::set_show_hidden_files` and `AsyncFileDialog::set_show_hidden_files` to control hidden file visibility. Supported on macOS, Windows, and Linux (GTK3). - Honor `FileDialog::set_directory` and `AsyncFileDialog::set_directory` when using the zenity fallback backend on Linux. - Fix the zenity fallback returning `None` for a file name that isn't valid UTF-8. zenity's output is now kept as bytes. +- Return `None` instead of `Some(vec![])` when `pick_files` or `pick_folders` is cancelled in the zenity fallback, as the XDG portal backend does since #323. - Fix `liblary` typo in docs - Change wasm title to sanitize text contents via `title_el.set_text_content()` rather than `title_el.set_inner_html()`. - Set wasm dialog styles via `set_text_content()` rather than `set_inner_html()`, so rfd no longer uses `innerHTML`. diff --git a/src/backend/linux/zenity.rs b/src/backend/linux/zenity.rs index d5f9d569..dc1d92ac 100644 --- a/src/backend/linux/zenity.rs +++ b/src/backend/linux/zenity.rs @@ -102,7 +102,7 @@ pub async fn pick_file(dialog: &FileDialog) -> ZenityResult> { .map(to_path)) } -pub async fn pick_files(dialog: &FileDialog) -> ZenityResult> { +pub async fn pick_files(dialog: &FileDialog) -> ZenityResult>> { let mut command = file_dialog_command(dialog); command.args(["--file-selection", "--multiple", "--separator=\n"]); @@ -110,8 +110,7 @@ pub async fn pick_files(dialog: &FileDialog) -> ZenityResult> { .await? .as_deref() .map(strip_newline) - .map(|s| s.split(|&byte| byte == b'\n').map(to_path).collect()) - .unwrap_or_default()) + .map(|s| s.split(|&byte| byte == b'\n').map(to_path).collect())) } pub async fn pick_folder(dialog: &FileDialog) -> ZenityResult> { @@ -125,7 +124,7 @@ pub async fn pick_folder(dialog: &FileDialog) -> ZenityResult> { .map(to_path)) } -pub async fn pick_folders(dialog: &FileDialog) -> ZenityResult> { +pub async fn pick_folders(dialog: &FileDialog) -> ZenityResult>> { let mut command = file_dialog_command(dialog); command.args([ "--file-selection", @@ -138,8 +137,7 @@ pub async fn pick_folders(dialog: &FileDialog) -> ZenityResult> { .await? .as_deref() .map(strip_newline) - .map(|s| s.split(|&byte| byte == b'\n').map(to_path).collect()) - .unwrap_or_default()) + .map(|s| s.split(|&byte| byte == b'\n').map(to_path).collect())) } pub async fn save_file(dialog: &FileDialog) -> ZenityResult> { diff --git a/src/backend/xdg_desktop_portal.rs b/src/backend/xdg_desktop_portal.rs index b0c9b3fa..c75526db 100644 --- a/src/backend/xdg_desktop_portal.rs +++ b/src/backend/xdg_desktop_portal.rs @@ -134,7 +134,7 @@ impl FilePickerDialogImpl for FileDialog { } else { warn!("Using zenity fallback"); match block_on(zenity::pick_files(&self)) { - Ok(res) => Some(res), + Ok(res) => res, Err(err) => { error!("Failed to pick files with zenity: {err}"); None @@ -219,7 +219,7 @@ impl FolderPickerDialogImpl for FileDialog { } else { warn!("Using zenity fallback"); match block_on(zenity::pick_folders(&self)) { - Ok(res) => Some(res), + Ok(res) => res, Err(err) => { error!("Failed to pick folders with zenity: {err}"); None From e95c431f8ea2e781fe80718eb90fa618851c860f Mon Sep 17 00:00:00 2001 From: Joel Tony Date: Mon, 19 Oct 2026 19:12:55 +0530 Subject: [PATCH 3/4] zenity: Replace `|` in filter names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rfd passes each filter to zenity as `NAME | PATTERNS`. zenity ends the name at the first `|` and splits the rest into patterns, with no way to escape it, so a filter named "Text | Markdown" was shown as "Text" and matched a file named `Markdown`. Show `|` in the name as `/`. A lookalike such as `∣` (U+2223) would read better, but zenity converts `--file-filter` arguments from the locale's charset, and in the C locale a filter name that isn't ASCII makes zenity exit without showing the dialog. --- CHANGELOG.md | 1 + src/backend/linux/zenity.rs | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24d54df7..cd318826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Honor `FileDialog::set_directory` and `AsyncFileDialog::set_directory` when using the zenity fallback backend on Linux. - Fix the zenity fallback returning `None` for a file name that isn't valid UTF-8. zenity's output is now kept as bytes. - Return `None` instead of `Some(vec![])` when `pick_files` or `pick_folders` is cancelled in the zenity fallback, as the XDG portal backend does since #323. +- Fix filter names that contain `|` in the zenity fallback, which zenity cut off at the `|`, reading the rest as file patterns. `|` in a filter name is now shown as `/`. - Fix `liblary` typo in docs - Change wasm title to sanitize text contents via `title_el.set_text_content()` rather than `title_el.set_inner_html()`. - Set wasm dialog styles via `set_text_content()` rather than `set_inner_html()`, so rfd no longer uses `innerHTML`. diff --git a/src/backend/linux/zenity.rs b/src/backend/linux/zenity.rs index dc1d92ac..693866ec 100644 --- a/src/backend/linux/zenity.rs +++ b/src/backend/linux/zenity.rs @@ -53,7 +53,10 @@ fn file_dialog_command(dialog: &FileDialog) -> Command { } }) .collect(); - command.arg(format!("{} | {}", filter.name, extensions.join(" "))); + // zenity ends the name at the first `|`, with no way to escape it, so show `/` instead. + // A non-ASCII lookalike would stop zenity from starting in the C locale. + let name = filter.name.replace('|', "/"); + command.arg(format!("{} | {}", name, extensions.join(" "))); } if let Some(name) = dialog.file_name.as_ref() { @@ -326,4 +329,18 @@ mod tests { ); assert_eq!(super::strip_newline(b"x\n\n"), b"x\n"); } + + #[test] + fn file_filter_name_with_pipe() { + let dialog = FileDialog::new().add_filter("Text | Markdown", &["txt", "md"]); + let command = super::file_dialog_command(&dialog); + assert_eq!( + command.get_args().collect::>(), + [ + "--no-markup", + "--file-filter", + "Text / Markdown | *.txt *.md" + ] + ); + } } From 68f8a0d4caff0d7860e52ec876657cec4b50020e Mon Sep 17 00:00:00 2001 From: Joel Tony Date: Mon, 19 Oct 2026 19:12:56 +0530 Subject: [PATCH 4/4] zenity: Pass the starting directory with --filename #317 passed the starting directory as zenity's working directory. With GTK's default file chooser settings, open and folder dialogs could start on Recent instead; save dialogs already started in the working directory. Pass the directory with `--filename` instead, ending in `/`, or joined with the name from `set_file_name`. zenity opens the folder of a `--filename` path itself, and zenity 3 only does so for an absolute path, so a relative directory is made absolute first. zenity 4 ignores `/`, which is its own base name, so the root is passed as `/./`. zenity is also no longer started in the directory, so one that doesn't exist doesn't make starting zenity fail, which returned `None` without showing a dialog. A `..` in the directory is now resolved by zenity without following symlinks, like `cd ..` in a shell, where `chdir` followed them. --- CHANGELOG.md | 1 + src/backend/linux/zenity.rs | 73 ++++++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd318826..35914c8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Fix the zenity fallback returning `None` for a file name that isn't valid UTF-8. zenity's output is now kept as bytes. - Return `None` instead of `Some(vec![])` when `pick_files` or `pick_folders` is cancelled in the zenity fallback, as the XDG portal backend does since #323. - Fix filter names that contain `|` in the zenity fallback, which zenity cut off at the `|`, reading the rest as file patterns. `|` in a filter name is now shown as `/`. +- Open the directory set with `set_directory` in the zenity fallback's open and folder dialogs, which could start on Recent with GTK's default file chooser settings. The directory is now passed with `--filename` rather than as zenity's working directory, and a directory that doesn't exist no longer stops the dialog from opening. - Fix `liblary` typo in docs - Change wasm title to sanitize text contents via `title_el.set_text_content()` rather than `title_el.set_inner_html()`. - Set wasm dialog styles via `set_text_content()` rather than `set_inner_html()`, so rfd no longer uses `innerHTML`. diff --git a/src/backend/linux/zenity.rs b/src/backend/linux/zenity.rs index 693866ec..3f861bd2 100644 --- a/src/backend/linux/zenity.rs +++ b/src/backend/linux/zenity.rs @@ -59,13 +59,26 @@ fn file_dialog_command(dialog: &FileDialog) -> Command { command.arg(format!("{} | {}", name, extensions.join(" "))); } - if let Some(name) = dialog.file_name.as_ref() { - command.arg("--filename"); - command.arg(name); - } + // zenity starts in the folder of an absolute `--filename`, or in the path itself when it ends + // with `/`, whatever GTK's file chooser `startup-mode` is + let path = match (&dialog.starting_directory, &dialog.file_name) { + (Some(dir), name) => { + let dir = std::path::absolute(dir).unwrap_or_else(|_| dir.clone()); + // Joining an empty name adds the trailing `/` + let path = dir.join(name.as_deref().unwrap_or_default()); + // zenity 4 ignores `/`, as it's its own base name + Some(if path.as_os_str() == "/" { + "/./".into() + } else { + path + }) + } + (None, name) => name.as_ref().map(PathBuf::from), + }; - if let Some(dir) = dialog.starting_directory.as_ref() { - command.current_dir(dir); + if let Some(path) = path { + command.arg("--filename"); + command.arg(path); } command @@ -343,4 +356,52 @@ mod tests { ] ); } + + #[test] + fn file_dialog_starting_directory() { + let dialog = FileDialog::new().set_directory("/home/user/Documents"); + let command = super::file_dialog_command(&dialog); + assert_eq!( + command.get_args().collect::>(), + ["--no-markup", "--filename", "/home/user/Documents/"] + ); + assert_eq!(command.get_current_dir(), None); + + let dialog = dialog.set_file_name("report.txt"); + let command = super::file_dialog_command(&dialog); + assert_eq!( + command.get_args().collect::>(), + [ + "--no-markup", + "--filename", + "/home/user/Documents/report.txt" + ] + ); + + let dialog = FileDialog::new().set_directory("Documents"); + let command = super::file_dialog_command(&dialog); + let documents = std::env::current_dir().unwrap().join("Documents/"); + assert_eq!( + command.get_args().collect::>(), + [ + "--no-markup".as_ref(), + "--filename".as_ref(), + documents.as_os_str() + ] + ); + + let dialog = FileDialog::new().set_directory("/"); + let command = super::file_dialog_command(&dialog); + assert_eq!( + command.get_args().collect::>(), + ["--no-markup", "--filename", "/./"] + ); + + let dialog = FileDialog::new().set_file_name("report.txt"); + let command = super::file_dialog_command(&dialog); + assert_eq!( + command.get_args().collect::>(), + ["--no-markup", "--filename", "report.txt"] + ); + } }