Save As: cancel a stalled portal request and recognize the Flatpak document portal (#6180)

- Recognize the XDG document portal mount inside a Flatpak sandbox
  (`/run/flatpak/doc/`), so a changed extension reopens the dialog for the
  overwrite confirmation like the host mount does.
- Give the portal SaveFile request a real cancellation path: the CancelToken is
  armed before the waiting thread starts and cancelled when the system dialog is
  given up (File > Open > Browse in WordCraft...). The request closes and the
  answer reads as a cancel, so a portal that stops answering cannot keep Save As
  stuck; a late cancel after the answer is ignored.
- Map the cancellation error to a cancelled dialog instead of the rfd fallback.
- Regression tests for the Flatpak mount, the CancelHandle arm/cancel/disarm,
  and the UI calling the host's cancel hook when the dialog is given up.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
batistalucasvandCommandCodeBot committed 2026-11-12 10:15:36 -03:00
1 parent cc27f29a01
commit 4671e47b50
6 files changed
+125 -10

No files matched your search

+76 -2
View File
@@ -23,6 +23,8 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::mpsc::{Receiver, Sender, channel};
#[cfg(all(unix, not(target_os = "macos")))]
use std::sync::{Arc, Mutex};
use wordcraft_ui_egui::{FileDialogAnswer, FileDialogRequest};
@@ -32,6 +34,45 @@ type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
/// The `Services::file_dialog` hook.
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
/// Cancels the portal Save dialog the launcher is waiting on, if any.
///
/// The portal's dialog lives outside the app's window, so a portal that stops answering cannot be
/// dismissed from WordCraft. Giving the dialog up (File › Open › Browse in WordCraft…) cancels the
/// request: the waiting thread closes it and reports a cancellation, and the built-in browser
/// opens instead. A no-op where Save As doesn't ask the portal (macOS and Windows).
#[derive(Clone, Default)]
pub struct CancelHandle {
#[cfg(all(unix, not(target_os = "macos")))]
token: Arc<Mutex<Option<crate::portal_save::CancelToken>>>,
}
impl CancelHandle {
/// Cancels the pending portal request, if there is one.
pub fn cancel(&self) {
#[cfg(all(unix, not(target_os = "macos")))]
{
let mut slot = self.token.lock().unwrap_or_else(|e| e.into_inner());
if let Some(token) = slot.take() {
token.cancel();
}
}
}
/// Arms the handle for the portal request about to wait, returning its token.
#[cfg(all(unix, not(target_os = "macos")))]
fn arm(&self) -> crate::portal_save::CancelToken {
let token = crate::portal_save::CancelToken::new();
*self.token.lock().unwrap_or_else(|e| e.into_inner()) = Some(token.clone());
token
}
/// Disarms the handle once the request has answered.
#[cfg(all(unix, not(target_os = "macos")))]
fn disarm(&self) {
*self.token.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
}
/// A picker's answer, waited for off the UI thread.
type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
@@ -39,6 +80,7 @@ type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
pub struct Launcher {
queue: Receiver<Request>,
ctx: egui::Context,
cancel: CancelHandle,
}
/// The hook for [`Services::file_dialog`](wordcraft_ui_egui::Services::file_dialog) and the
@@ -51,10 +93,15 @@ pub fn hook(ctx: &egui::Context) -> (Hook, Launcher) {
let _ = queue_tx.send((req, answer));
rx
});
(hook, Launcher { queue, ctx: ctx.clone() })
(hook, Launcher { queue, ctx: ctx.clone(), cancel: CancelHandle::default() })
}
impl Launcher {
/// A handle that cancels the portal Save dialog the launcher is waiting on, if any.
pub fn cancel_handle(&self) -> CancelHandle {
self.cancel.clone()
}
/// Show the queued dialogs with the window as their parent, so the portal (or the system)
/// keeps them on top of it. Call after every pass that may ask for one.
pub fn show(&self, parent: &eframe::Frame) {
@@ -68,6 +115,11 @@ impl Launcher {
use eframe::wgpu::rwh::HasWindowHandle;
crate::portal_save::parent_id(parent.window_handle().ok().map(|h| h.as_raw()))
};
// Armed before the thread starts, so giving the dialog up cannot race its start.
#[cfg(all(unix, not(target_os = "macos")))]
let portal_cancel = matches!(&req, FileDialogRequest::Save { .. }).then(|| self.cancel.arm());
#[cfg(all(unix, not(target_os = "macos")))]
let cancel_handle = self.cancel.clone();
let ctx = self.ctx.clone();
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
// The pickers only start when first polled: without a system dialog they never do.
@@ -84,7 +136,7 @@ impl Launcher {
folder: dir.clone().map(std::path::PathBuf::from),
filters: save_filters(name),
current: Some(0),
cancel: None,
cancel: portal_cancel,
timeout: None,
};
let portal_res = zbus::blocking::Connection::session()
@@ -93,6 +145,7 @@ impl Launcher {
match portal_res {
Ok(Some(path)) => Ok(vec![path.to_string_lossy().into_owned()]),
Ok(None) => Ok(Vec::new()),
Err(e) if crate::portal_save::is_cancelled_error(&e) => Ok(Vec::new()),
Err(e) if crate::portal_save::is_ambiguous_filter_error(&e) => {
log::warn!("portal save format ambiguous ({e}); cancelling save without explicit format choice");
Ok(Vec::new())
@@ -116,12 +169,16 @@ impl Launcher {
}
}
};
#[cfg(all(unix, not(target_os = "macos")))]
cancel_handle.disarm();
let _ = answer.send(picked);
ctx.request_repaint();
});
// The answer's sender went with the closure, so the app sees the dialog as cancelled.
if let Err(e) = waiting {
log::error!("couldn't wait for the file dialog: {e}");
#[cfg(all(unix, not(target_os = "macos")))]
self.cancel.disarm();
}
}
}
@@ -312,6 +369,23 @@ mod tests {
assert_eq!(pick(async { vec![] }, slow_cancel(), quick), Ok(vec![]), "the second dialog cancelled for real");
}
/// Giving the portal Save dialog up cancels the request the launcher armed, and a request
/// that has already answered is not affected by a later cancel.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn cancelling_a_pending_portal_request_marks_its_token() {
let handle = super::CancelHandle::default();
let token = handle.arm();
assert!(!token.is_cancelled());
handle.cancel();
assert!(token.is_cancelled());
let answered = handle.arm();
handle.disarm();
handle.cancel();
assert!(!answered.is_cancelled(), "an answered request is no longer cancelled");
}
/// #1399: the Save dialog's type list has one named entry per format, the suggested one first,
/// every savable extension exactly once.
#[test]
+4 -2
View File
@@ -340,9 +340,10 @@ fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
/// through the system spooler (`print`), the system clipboard's text, formatted copies,
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
fn services(file_dialog: file_dialogs::Hook, cancel_file_dialog: file_dialogs::CancelHandle, ctx: &egui::Context) -> Services {
Services {
file_dialog: Some(file_dialog),
cancel_file_dialog: Some(Box::new(move || cancel_file_dialog.cancel())),
printers: Some(print::discover()),
print_native: Some(print::submit()),
cancel_print: Some(print::cancel()),
@@ -584,9 +585,10 @@ fn main() -> eframe::Result {
let region = region::system();
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
let cancel_file_dialog = dialogs.cancel_handle();
let mut session = Session::new(doc);
session.system_region = region;
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
let mut app = WordApp::new(session, services(file_dialog, cancel_file_dialog, &cc.egui_ctx));
app.set_startup_update_check(true);
load_prefs(&mut app);
// After the preferences, so the reason it gives is in the interface language.
+17 -6
View File
@@ -31,13 +31,11 @@ pub struct CancelToken(std::sync::Arc<std::sync::atomic::AtomicBool>);
impl CancelToken {
/// Creates a new cancellation token in the active (not cancelled) state.
#[allow(dead_code)]
pub fn new() -> Self {
Self(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)))
}
/// Signals that the active save request should be cancelled.
#[allow(dead_code)]
pub fn cancel(&self) {
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
}
@@ -357,7 +355,7 @@ pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result
let reply = loop {
if req.cancel.as_ref().is_some_and(CancelToken::is_cancelled) {
return Err("the save dialog request was cancelled".to_string());
return Err(ERR_CANCELLED.to_string());
}
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(m) => {
@@ -397,10 +395,20 @@ pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result
answer(code, &results, elapsed)
}
/// Returns true if `path` is located in the XDG document portal (`/run/user/<uid>/doc/…`).
/// Returns true if `path` is located in the XDG document portal (`/run/user/<uid>/doc/…`, or
/// `/run/flatpak/doc/…` inside a Flatpak sandbox).
fn is_document_portal(path: &Path) -> bool {
let s = path.to_string_lossy();
(s.starts_with("/run/user/") || s.starts_with("/var/run/user/")) && s.contains("/doc/")
((s.starts_with("/run/user/") || s.starts_with("/var/run/user/")) && s.contains("/doc/")) || s.starts_with("/run/flatpak/doc/")
}
/// Error message returned when a pending save request was cancelled by the caller.
pub const ERR_CANCELLED: &str = "the save dialog request was cancelled";
/// Returns true if `err` represents a save request cancelled by the caller (not a cancel in the
/// dialog itself, which is `Ok(None)`).
pub fn is_cancelled_error(err: &str) -> bool {
err == ERR_CANCELLED
}
/// Error message returned when the portal dialog does not report the selected filter and
@@ -838,13 +846,16 @@ mod tests {
assert!(!entry_exists(&target), "nonexistent file does not exist");
}
/// `is_document_portal` identifies paths within the XDG document portal mount.
/// `is_document_portal` identifies paths within the XDG document portal mount, including the
/// `/run/flatpak/doc` mount a Flatpak sandbox sees.
#[test]
fn is_document_portal_detects_portal_fuse_paths() {
assert!(is_document_portal(Path::new("/run/user/1000/doc/1234abcd/test.docx")));
assert!(is_document_portal(Path::new("/var/run/user/1000/doc/by-app/app/test.docx")));
assert!(is_document_portal(Path::new("/run/flatpak/doc/1234abcd/test.docx")));
assert!(!is_document_portal(Path::new("/home/user/Documents/test.docx")));
assert!(!is_document_portal(Path::new("/tmp/doc/test.docx")));
assert!(!is_document_portal(Path::new("/run/flatpak/other/test.docx")));
}
/// When the portal omits `current_filter`, ambiguous saves (unchanged suggested extension or bare name)
+3
View File
@@ -174,6 +174,9 @@ impl WordApp {
/// Treat the system dialog the host is showing as cancelled, e.g. one that never showed up.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn give_up_file_dialog(&mut self) {
if let Some(cancel) = &self.services.cancel_file_dialog {
cancel();
}
if let Some(d) = self.file_dialog.take() {
let _ = self.finish_pick(d.after, None);
}
+21
View File
@@ -252,6 +252,27 @@ fn answer_all(shown: &Shown, picked: &[&str]) {
tx.send(Ok(picked.iter().map(|p| p.to_string()).collect())).unwrap();
}
/// Giving the system dialog up (File › Open › Browse in WordCraft…) tells the host to cancel its
/// waiting request, so a portal that stopped answering cannot keep Save As stuck.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn giving_up_a_file_dialog_cancels_the_host_request() {
let cancelled = Rc::new(std::cell::Cell::new(false));
let flag = cancelled.clone();
let services = Services { cancel_file_dialog: Some(Box::new(move || flag.set(true))), ..Default::default() };
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
let (tx, rx) = channel();
a.file_dialog = Some(PendingDialog {
rx,
req: FileDialogRequest::Save { name: "Document1.docx".into(), dir: None },
after: AfterPick::Export { ext: "pdf".into() },
});
a.give_up_file_dialog();
assert!(cancelled.get(), "the host is told to stop waiting");
assert!(a.file_dialog.is_none(), "the pending dialog is taken as cancelled");
drop(tx);
}
fn typed(a: &mut WordApp) {
a.run("text.insert", json!({"text": UNSAVED})).unwrap();
assert!(a.session.dirty);
+4
View File
@@ -21,6 +21,10 @@ pub struct Services {
/// sender counts as cancelled. An `Err` says no system dialog could be shown, and the app shows
/// its built-in file browser instead (#576). The app shows one dialog at a time ([`file_dialogs`]).
pub file_dialog: Option<Box<dyn Fn(FileDialogRequest) -> std::sync::mpsc::Receiver<FileDialogAnswer>>>,
/// Desktop: stop waiting for the system file dialog the host is showing, if it can (a portal
/// that never answers). The app calls it when it gives the dialog up (File › Open › Browse in
/// WordCraft…), so the host closes the request and answers as cancelled.
pub cancel_file_dialog: Option<Box<dyn Fn()>>,
/// Web: open a file picker; the file arrives later through `inbox`.
pub open_async: Option<Box<dyn Fn(&str)>>,
/// Web: files (name, bytes) delivered asynchronously (picker, drag and drop).