From 4671e47b50311c16c3e28a044eedf43a5bc191fb Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Thu, 12 Nov 2026 10:15:36 -0300 Subject: [PATCH] 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 --- apps/wordcraft/src/file_dialogs.rs | 78 +++++++++++++++++++++++- apps/wordcraft/src/main.rs | 6 +- apps/wordcraft/src/portal_save.rs | 23 +++++-- crates/ui-egui/src/file_dialogs.rs | 3 + crates/ui-egui/src/file_dialogs/tests.rs | 21 +++++++ crates/ui-egui/src/services.rs | 4 ++ 6 files changed, 125 insertions(+), 10 deletions(-) diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 139da8b127..50880927bc 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -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); /// The `Services::file_dialog` hook. pub type Hook = Box Receiver>; +/// 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>>, +} + +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> + Send>>; @@ -39,6 +80,7 @@ type Picking = Pin> + Send>>; pub struct Launcher { queue: Receiver, 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] diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 00f74024c2..ee5f38be8b 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -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(®ion) }; 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. diff --git a/apps/wordcraft/src/portal_save.rs b/apps/wordcraft/src/portal_save.rs index b5fc05605f..4edf20bbac 100644 --- a/apps/wordcraft/src/portal_save.rs +++ b/apps/wordcraft/src/portal_save.rs @@ -31,13 +31,11 @@ pub struct CancelToken(std::sync::Arc); 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//doc/…`). +/// Returns true if `path` is located in the XDG document portal (`/run/user//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) diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index a4399b6f00..f6a3ff16a2 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -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); } diff --git a/crates/ui-egui/src/file_dialogs/tests.rs b/crates/ui-egui/src/file_dialogs/tests.rs index 837cb96a87..853f22da1f 100644 --- a/crates/ui-egui/src/file_dialogs/tests.rs +++ b/crates/ui-egui/src/file_dialogs/tests.rs @@ -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); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index d9359b973b..a144ecd197 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -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 std::sync::mpsc::Receiver>>, + /// 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>, /// Web: open a file picker; the file arrives later through `inbox`. pub open_async: Option>, /// Web: files (name, bytes) delivered asynchronously (picker, drag and drop).