|
| 1 | +//! Direct zenity fallback for Linux file dialogs (issue #95). |
| 2 | +//! |
| 3 | +//! The dialog plugin is compiled portal-only (`tauri-plugin-dialog` |
| 4 | +//! with the `xdg-portal` feature → rfd → ashpd). rfd *does* fall back |
| 5 | +//! to spawning `zenity`, but only when the portal call returns an |
| 6 | +//! error. On minimal window-manager sessions the portal frequently |
| 7 | +//! *hangs* instead (the preflight catches that via timeout), so rfd's |
| 8 | +//! own fallback never runs — and when it does run, rfd spawns zenity |
| 9 | +//! with the inherited session environment, the very environment that |
| 10 | +//! broke the portal in the first place (a stale/dead D-Bus session |
| 11 | +//! bus, or a GTK module that blocks `gtk_init`), so zenity hangs too. |
| 12 | +//! |
| 13 | +//! This module is the escape hatch: when the preflight decides the |
| 14 | +//! portal is unusable but a `zenity` binary exists, the dialog command |
| 15 | +//! calls in here instead of rfd. We spawn zenity ourselves with |
| 16 | +//! |
| 17 | +//! 1. a **sanitized environment** — the vars that hang GTK clients on |
| 18 | +//! broken sessions are cleared, and the accessibility bridge (a |
| 19 | +//! classic source of multi-second `gtk_init` stalls) is disabled; |
| 20 | +//! 2. a **hard timeout** — a wedged zenity can never leave the picker |
| 21 | +//! button dead forever; it resolves as a cancel-with-error instead. |
| 22 | +//! |
| 23 | +//! Non-Linux platforms never use this module: their native dialogs |
| 24 | +//! need no external process. |
| 25 | +
|
| 26 | +#![cfg(target_os = "linux")] |
| 27 | + |
| 28 | +use std::path::{Path, PathBuf}; |
| 29 | +use std::process::Stdio; |
| 30 | +use std::time::Duration; |
| 31 | + |
| 32 | +/// Environment variables cleared for the zenity subprocess. On a |
| 33 | +/// healthy desktop these are harmless to keep; we only ever reach this |
| 34 | +/// module when the portal is already broken, which strongly correlates |
| 35 | +/// with the session environment below being the cause. |
| 36 | +/// |
| 37 | +/// - `DBUS_SESSION_BUS_ADDRESS`: when it points at a dead or wedged bus |
| 38 | +/// (e.g. a greeter's address that outlived its bus), GTK blocks |
| 39 | +/// talking to it during init. Clearing it lets GTK autolaunch a |
| 40 | +/// private session bus, or run without one — either way it no longer |
| 41 | +/// hangs. |
| 42 | +/// - `GTK_MODULES` / `GTK3_MODULES`: a module that blocks (a stale |
| 43 | +/// accessibility bridge is the usual culprit) stalls `gtk_init`. |
| 44 | +const SANITIZE_VARS: &[&str] = &["DBUS_SESSION_BUS_ADDRESS", "GTK_MODULES", "GTK3_MODULES"]; |
| 45 | + |
| 46 | +/// Default safety timeout. Generous enough that a user genuinely |
| 47 | +/// browsing the filesystem is never cut off, but bounded so a zenity |
| 48 | +/// wedged at init eventually releases the dialog. Overridable via |
| 49 | +/// `CODEMUX_ZENITY_TIMEOUT_MS` (used by tests, and as an operational |
| 50 | +/// escape hatch). |
| 51 | +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(180); |
| 52 | + |
| 53 | +fn timeout() -> Duration { |
| 54 | + std::env::var("CODEMUX_ZENITY_TIMEOUT_MS") |
| 55 | + .ok() |
| 56 | + .and_then(|raw| raw.parse::<u64>().ok()) |
| 57 | + .map(Duration::from_millis) |
| 58 | + .unwrap_or(DEFAULT_TIMEOUT) |
| 59 | +} |
| 60 | + |
| 61 | +/// A zenity command pre-seeded with the sanitized environment. |
| 62 | +fn base_command(zenity: &Path) -> tokio::process::Command { |
| 63 | + let mut cmd = tokio::process::Command::new(zenity); |
| 64 | + cmd.arg("--no-markup"); |
| 65 | + for var in SANITIZE_VARS { |
| 66 | + cmd.env_remove(var); |
| 67 | + } |
| 68 | + // Disable the at-spi accessibility bridge for this child. Without a |
| 69 | + // running a11y bus, GTK clients can block for many seconds during |
| 70 | + // init waiting for it; the file picker needs none of it. |
| 71 | + cmd.env("NO_AT_BRIDGE", "1"); |
| 72 | + cmd.env("GTK_A11Y", "none"); |
| 73 | + cmd.kill_on_drop(true); |
| 74 | + cmd |
| 75 | +} |
| 76 | + |
| 77 | +/// Apply rfd-compatible `--file-filter NAME | *.ext *.ext` arguments. |
| 78 | +fn add_filters(cmd: &mut tokio::process::Command, name: Option<&str>, extensions: Option<&[String]>) { |
| 79 | + if let (Some(name), Some(exts)) = (name, extensions) { |
| 80 | + if !exts.is_empty() { |
| 81 | + let globs: Vec<String> = exts.iter().map(|ext| format!("*.{ext}")).collect(); |
| 82 | + cmd.arg("--file-filter"); |
| 83 | + cmd.arg(format!("{name} | {}", globs.join(" "))); |
| 84 | + } |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +/// Run a prepared zenity command to completion under the timeout. |
| 89 | +/// |
| 90 | +/// Returns `Ok(Some(stdout))` when the user confirmed a selection |
| 91 | +/// (zenity exits 0 with output), `Ok(None)` on cancel (exit 1 / empty), |
| 92 | +/// and `Err(msg)` when zenity could not be run or timed out. |
| 93 | +async fn run(mut cmd: tokio::process::Command) -> Result<Option<String>, String> { |
| 94 | + let child = cmd |
| 95 | + .stdout(Stdio::piped()) |
| 96 | + .stderr(Stdio::null()) |
| 97 | + .spawn() |
| 98 | + .map_err(|err| format!("could not launch zenity: {err}"))?; |
| 99 | + |
| 100 | + match tokio::time::timeout(timeout(), child.wait_with_output()).await { |
| 101 | + // Timed out: the future is dropped here, and `kill_on_drop` |
| 102 | + // reaps the wedged zenity so it can't linger. |
| 103 | + Err(_) => Err("zenity timed out (no dialog appeared)".to_string()), |
| 104 | + Ok(Err(err)) => Err(format!("zenity failed: {err}")), |
| 105 | + Ok(Ok(output)) => { |
| 106 | + let stdout = String::from_utf8_lossy(&output.stdout); |
| 107 | + let trimmed = stdout.trim(); |
| 108 | + if output.status.success() && !trimmed.is_empty() { |
| 109 | + Ok(Some(trimmed.to_string())) |
| 110 | + } else { |
| 111 | + // Non-zero exit or empty output == user cancelled. |
| 112 | + Ok(None) |
| 113 | + } |
| 114 | + } |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +/// Folder picker. `Ok(None)` on cancel. |
| 119 | +pub async fn pick_folder(zenity: &Path, title: &str) -> Result<Option<PathBuf>, String> { |
| 120 | + let mut cmd = base_command(zenity); |
| 121 | + cmd.args(["--file-selection", "--directory", "--title", title]); |
| 122 | + Ok(run(cmd).await?.map(PathBuf::from)) |
| 123 | +} |
| 124 | + |
| 125 | +/// Multi-file picker. Returns an empty vec on cancel. |
| 126 | +pub async fn pick_files(zenity: &Path, title: &str) -> Result<Vec<PathBuf>, String> { |
| 127 | + let mut cmd = base_command(zenity); |
| 128 | + // Newline separator so paths containing the default `|` don't split |
| 129 | + // incorrectly. |
| 130 | + cmd.args([ |
| 131 | + "--file-selection", |
| 132 | + "--multiple", |
| 133 | + "--separator", |
| 134 | + "\n", |
| 135 | + "--title", |
| 136 | + title, |
| 137 | + ]); |
| 138 | + Ok(run(cmd) |
| 139 | + .await? |
| 140 | + .map(|out| out.lines().map(PathBuf::from).collect()) |
| 141 | + .unwrap_or_default()) |
| 142 | +} |
| 143 | + |
| 144 | +/// Single-file open dialog with an optional filter. `Ok(None)` on cancel. |
| 145 | +pub async fn pick_open_file( |
| 146 | + zenity: &Path, |
| 147 | + title: &str, |
| 148 | + filter_name: Option<&str>, |
| 149 | + filter_extensions: Option<&[String]>, |
| 150 | +) -> Result<Option<PathBuf>, String> { |
| 151 | + let mut cmd = base_command(zenity); |
| 152 | + cmd.args(["--file-selection", "--title", title]); |
| 153 | + add_filters(&mut cmd, filter_name, filter_extensions); |
| 154 | + Ok(run(cmd).await?.map(PathBuf::from)) |
| 155 | +} |
| 156 | + |
| 157 | +/// Save-as dialog. `Ok(None)` on cancel. |
| 158 | +pub async fn save_file( |
| 159 | + zenity: &Path, |
| 160 | + title: &str, |
| 161 | + default_filename: Option<&str>, |
| 162 | + filter_name: Option<&str>, |
| 163 | + filter_extensions: Option<&[String]>, |
| 164 | +) -> Result<Option<PathBuf>, String> { |
| 165 | + let mut cmd = base_command(zenity); |
| 166 | + cmd.args([ |
| 167 | + "--file-selection", |
| 168 | + "--save", |
| 169 | + "--confirm-overwrite", |
| 170 | + "--title", |
| 171 | + title, |
| 172 | + ]); |
| 173 | + if let Some(name) = default_filename { |
| 174 | + cmd.args(["--filename", name]); |
| 175 | + } |
| 176 | + add_filters(&mut cmd, filter_name, filter_extensions); |
| 177 | + Ok(run(cmd).await?.map(PathBuf::from)) |
| 178 | +} |
0 commit comments