Skip to content

Commit 517ef94

Browse files
authored
Merge pull request #103 from Zeus-Deus/The-branch-name-is-for-analyzing-a-workflow-issue
fix(dialog): make file pickers work on portal-less Linux sessions (#95)
2 parents 51cb959 + 6f69951 commit 517ef94

8 files changed

Lines changed: 473 additions & 19 deletions

File tree

src-tauri/src/commands/mod.rs

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -312,10 +312,28 @@ pub async fn pick_folder_dialog<R: Runtime>(
312312
use tauri_plugin_dialog::DialogExt;
313313
use tokio::sync::oneshot;
314314

315-
// Fail loudly when no dialog backend exists (issue #95) — without
316-
// this the portal-only backend resolves `None` exactly like a user
317-
// cancel and the UI silently does nothing.
318-
crate::dialog_preflight::ensure_file_picker_backend().await?;
315+
// Decide the dialog backend up front (issue #95). On Linux, when
316+
// the portal is unusable but zenity exists, drive zenity ourselves
317+
// with a sanitized env + timeout instead of letting rfd inherit the
318+
// broken session environment (which hangs the picker).
319+
#[cfg(target_os = "linux")]
320+
{
321+
match crate::dialog_preflight::select_backend().await {
322+
crate::dialog_preflight::Backend::Portal => {}
323+
crate::dialog_preflight::Backend::Zenity(zenity) => {
324+
let dialog_title = title.clone().unwrap_or_else(|| "Choose folder".to_string());
325+
return Ok(crate::dialog_fallback::pick_folder(&zenity, &dialog_title)
326+
.await?
327+
.map(|path| path.to_string_lossy().into_owned()));
328+
}
329+
crate::dialog_preflight::Backend::None(message) => {
330+
return Err(format!(
331+
"{}: {message}",
332+
crate::dialog_preflight::NO_BACKEND_MARKER
333+
));
334+
}
335+
}
336+
}
319337

320338
let (tx, rx) = oneshot::channel();
321339

@@ -345,8 +363,27 @@ pub async fn pick_files_dialog<R: Runtime>(
345363
use tauri_plugin_dialog::DialogExt;
346364
use tokio::sync::oneshot;
347365

348-
// See pick_folder_dialog — same silent-failure guard (issue #95).
349-
crate::dialog_preflight::ensure_file_picker_backend().await?;
366+
// See pick_folder_dialog — same backend decision (issue #95).
367+
#[cfg(target_os = "linux")]
368+
{
369+
match crate::dialog_preflight::select_backend().await {
370+
crate::dialog_preflight::Backend::Portal => {}
371+
crate::dialog_preflight::Backend::Zenity(zenity) => {
372+
let dialog_title = title.clone().unwrap_or_else(|| "Attach files".to_string());
373+
return Ok(crate::dialog_fallback::pick_files(&zenity, &dialog_title)
374+
.await?
375+
.into_iter()
376+
.map(|path| path.to_string_lossy().into_owned())
377+
.collect());
378+
}
379+
crate::dialog_preflight::Backend::None(message) => {
380+
return Err(format!(
381+
"{}: {message}",
382+
crate::dialog_preflight::NO_BACKEND_MARKER
383+
));
384+
}
385+
}
386+
}
350387

351388
let (tx, rx) = oneshot::channel();
352389

@@ -388,8 +425,31 @@ pub async fn pick_save_file_dialog<R: Runtime>(
388425
use tauri_plugin_dialog::DialogExt;
389426
use tokio::sync::oneshot;
390427

391-
// See pick_folder_dialog — same silent-failure guard (issue #95).
392-
crate::dialog_preflight::ensure_file_picker_backend().await?;
428+
// See pick_folder_dialog — same backend decision (issue #95).
429+
#[cfg(target_os = "linux")]
430+
{
431+
match crate::dialog_preflight::select_backend().await {
432+
crate::dialog_preflight::Backend::Portal => {}
433+
crate::dialog_preflight::Backend::Zenity(zenity) => {
434+
let dialog_title = title.clone().unwrap_or_else(|| "Save as".to_string());
435+
return Ok(crate::dialog_fallback::save_file(
436+
&zenity,
437+
&dialog_title,
438+
default_filename.as_deref(),
439+
filter_name.as_deref(),
440+
filter_extensions.as_deref(),
441+
)
442+
.await?
443+
.map(|path| path.to_string_lossy().into_owned()));
444+
}
445+
crate::dialog_preflight::Backend::None(message) => {
446+
return Err(format!(
447+
"{}: {message}",
448+
crate::dialog_preflight::NO_BACKEND_MARKER
449+
));
450+
}
451+
}
452+
}
393453

394454
let (tx, rx) = oneshot::channel();
395455

@@ -433,8 +493,30 @@ pub async fn pick_open_file_dialog<R: Runtime>(
433493
use tauri_plugin_dialog::DialogExt;
434494
use tokio::sync::oneshot;
435495

436-
// See pick_folder_dialog — same silent-failure guard (issue #95).
437-
crate::dialog_preflight::ensure_file_picker_backend().await?;
496+
// See pick_folder_dialog — same backend decision (issue #95).
497+
#[cfg(target_os = "linux")]
498+
{
499+
match crate::dialog_preflight::select_backend().await {
500+
crate::dialog_preflight::Backend::Portal => {}
501+
crate::dialog_preflight::Backend::Zenity(zenity) => {
502+
let dialog_title = title.clone().unwrap_or_else(|| "Open".to_string());
503+
return Ok(crate::dialog_fallback::pick_open_file(
504+
&zenity,
505+
&dialog_title,
506+
filter_name.as_deref(),
507+
filter_extensions.as_deref(),
508+
)
509+
.await?
510+
.map(|path| path.to_string_lossy().into_owned()));
511+
}
512+
crate::dialog_preflight::Backend::None(message) => {
513+
return Err(format!(
514+
"{}: {message}",
515+
crate::dialog_preflight::NO_BACKEND_MARKER
516+
));
517+
}
518+
}
519+
}
438520

439521
let (tx, rx) = oneshot::channel();
440522

src-tauri/src/dialog_fallback.rs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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

Comments
 (0)