mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-13 22:12:22 +00:00
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:
1 parent
cc27f29a01
commit
4671e47b50
6 files changed
+125
-10
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user