From 468d7b21e9cf0b9ca5e8a97165bf040b6c2e957d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 10:09:24 -0700 Subject: [PATCH] Save As: on Linux the file type picked in the save dialog decides the format On Linux the save dialog is the XDG desktop portal's, shown through rfd. rfd sends no `current_filter` and reads only the file name from the portal's answer, and the GTK file chooser keeps the typed name when another file type is picked. With "PDF-compatible .ai" picked, `poster` typed for a new document was saved as a native `poster.vectorcraft`, and so was the suggested `Untitled-1.vectorcraft` left as it was. When a save offers two or more file types (Save As, Save a Copy, Save as Template and the first Save of a new document), the app now calls the portal's SaveFile itself over D-Bus. The dialog starts on the suggested file's type, and its answer names the type picked. A name typed without an extension, or the suggested name left as it was, gets that type's extension. A name typed with another save format's extension, other than the suggested file's (for Save as Template, a template format's), is saved in that format, and any other extension gets the picked type's added. The dialog keeps the window as its parent on X11 and Wayland, and rfd shows the dialog when the portal can't. The same code runs on the BSDs; Windows, macOS and the web build keep their dialogs. Docs: docs/target-app-parity.md. --- Cargo.lock | 2 + apps/vectorcraft/Cargo.toml | 14 +- apps/vectorcraft/src/main.rs | 21 +- apps/vectorcraft/src/portal_save.rs | 529 ++++++++++++++++++++++++++++ crates/ui-egui/src/io.rs | 36 +- crates/ui-egui/src/picks.rs | 19 +- crates/ui-egui/src/tests_saveext.rs | 85 +++++ docs/target-app-parity.md | 1 + 8 files changed, 689 insertions(+), 18 deletions(-) create mode 100644 apps/vectorcraft/src/portal_save.rs diff --git a/Cargo.lock b/Cargo.lock index a41f608e4..afaecd644 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4536,6 +4536,7 @@ dependencies = [ "vectorcraft-engine", "vectorcraft-text", "vectorcraft-ui-egui", + "wayland-backend", "wayland-client", "wayland-protocols", "webbrowser", @@ -4543,6 +4544,7 @@ dependencies = [ "winit", "winresource", "winsafe", + "zbus", ] [[package]] diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index f3ae115b6..28ccdaced 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -75,7 +75,17 @@ pollster = "1" # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs), # so Automatic draws on that one. Both are already in the build through winit. wayland-client = "0.31" -wayland-protocols = { version = "0.32", features = ["client"] } +wayland-protocols = { version = "0.32", features = ["client", "unstable"] } +# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in +# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build +# through winit and rfd. +wayland-backend = { version = "0.3", features = ["client_system"] } + +# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the +# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned +# exactly: review the diff before bumping. +[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies] +zbus = "=5.19.0" [build-dependencies] # Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows. @@ -84,3 +94,5 @@ winresource = "0.1" [dev-dependencies] # Bound socket buffers in real loopback transport deadline regressions. socket2 = { workspace = true } +# src/portal_save.rs's tests build and read the portal's D-Bus values on every system. +zbus = "=5.19.0" diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 2185c38db..172428780 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -43,6 +43,8 @@ mod mac_screen_color; mod mac_window; #[cfg(target_os = "macos")] mod open_documents; +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))] +mod portal_save; mod prefs_dir; mod printing; #[cfg(all(windows, not(target_vendor = "win7")))] @@ -359,10 +361,20 @@ fn make_folder(request: &PickRequest) { } } -/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. +/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On +/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over +/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when +/// the portal can't. #[cfg(not(target_os = "macos"))] -fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec { +#[cfg_attr(windows, allow(unused_variables))] +fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec { make_folder(request); + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + if let PickRequest::Save(pick) = request + && let Some(answer) = portal_save::pick(pick, parent.as_deref()) + { + return answer; + } let paths = match request { PickRequest::Open(_) => dialog.pick_file().into_iter().collect(), PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(), @@ -375,7 +387,7 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec { /// Show the dialog for `request` over `parent` now → the paths picked. #[cfg(not(target_os = "macos"))] fn pick_now(request: PickRequest, parent: &Parent) -> Vec { - show_dialog(file_dialog(&request, parent), &request) + show_dialog(file_dialog(&request, parent), &request, parent) } /// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an @@ -431,11 +443,12 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti let dialog = file_dialog(&request, parent); let (tx, rx) = std::sync::mpsc::channel(); let ui = ui.clone(); + let parent = parent.clone(); std::thread::Builder::new() .name("file-dialog".into()) .spawn(move || { // The app gone meanwhile has no use for the answer. - let _ = tx.send(show_dialog(dialog, &request)); + let _ = tx.send(show_dialog(dialog, &request, &parent)); ui.request_repaint(); }) .ok()?; diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs new file mode 100644 index 000000000..c374c8949 --- /dev/null +++ b/apps/vectorcraft/src/portal_save.rs @@ -0,0 +1,529 @@ +//! Linux and the BSDs: the save dialog of a save with two or more file types (Save, Save As, Save a +//! Copy, Save as Template; #2679), asked of the XDG desktop portal here +//! (). +//! rfd 0.17 sends no `current_filter` with `SaveFile` and reads only `uris` from the answer +//! (PolyMeilex/rfd#341), and the GTK file chooser behind the portal keeps the typed name when the +//! user picks another file type, so the type picked was lost. This call sends the first file type as +//! `current_filter`, reads the one picked back, and answers its first extension after the path +//! ([`vectorcraft_ui_egui::picks::PickAnswer`]); the app gives the name that type's extension. +//! rfd shows the dialog when the portal can't (no session bus, no file chooser), with zenity as its +//! own fallback. PhotoCraft's `portal_save.rs` (storytold/photocraft#6151) makes the same call. +//! +//! The dialog's parent is the window: `x11:` on X11, and on Wayland (Linux) a handle +//! from the compositor's xdg-foreign protocol, as rfd names it (its `window_identifier`). + +use std::collections::HashMap; + +use vectorcraft_ui_egui::FilePick; +use zbus::zvariant::{OwnedValue, Value}; + +/// A file type in the dialog: its label and its extensions without the dot. +type Filter = (String, Vec); + +/// A save dialog to show. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct SaveRequest { + /// The portal's identifier of the parent window, or empty. + parent: String, + /// The suggested name; empty for none. + name: String, + folder: Option, + /// The file types, labeled in the interface's language; the dialog starts on the first. + filters: Vec, +} + +impl SaveRequest { + /// The dialog for `pick` over the window `parent` names. + fn new(pick: &FilePick, parent: String) -> Self { + let lang = vectorcraft_ui_egui::i18n::current(); + let filters = pick + .filters + .iter() + .map(|(label, exts)| { + (vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", label).to_string(), exts.iter().map(|e| e.to_string()).collect()) + }) + .collect(); + Self { parent, name: pick.name.clone(), folder: pick.folder.clone(), filters } + } +} + +/// What the dialog answered: the path, as typed, and the label of the file type picked. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Saved { + path: String, + filter: Option, +} + +/// `filter` as the portal takes it: its label and a `*.ext` pattern for each extension (`*` for +/// every file), as rfd sends them. +fn wire(filter: &Filter) -> (String, Vec<(u32, String)>) { + let globs = filter.1.iter().map(|e| (0, if e.is_empty() || e == "*" { "*".to_string() } else { format!("*.{e}") })).collect(); + (filter.0.clone(), globs) +} + +/// The `SaveFile` options for `request`, answering on `token`. +fn options<'a>(request: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> { + let mut o = HashMap::from([("handle_token", Value::from(token))]); + if !request.name.is_empty() { + o.insert("current_name", Value::from(request.name.as_str())); + } + if let Some(folder) = &request.folder { + // A byte string ending in NUL, as the portal specifies. + let mut bytes = folder.as_bytes().to_vec(); + bytes.push(0); + o.insert("current_folder", Value::from(bytes)); + } + if let Some(first) = request.filters.first() { + o.insert("filters", Value::from(request.filters.iter().map(wire).collect::>())); + o.insert("current_filter", Value::from(wire(first))); + } + o +} + +/// The portal's `Response`: code 0 with the first of its `uris` and the label of its +/// `current_filter`. Any other code ends the dialog without a file (1 is Cancel, 2 a dialog closed +/// some other way), as rfd reads it. +fn answer(code: u32, results: &HashMap) -> Result, String> { + if code != 0 { + return Ok(None); + } + let uris: Vec = results + .get("uris") + .ok_or("no file in the portal's answer")? + .try_clone() + .map_err(|e| e.to_string())? + .try_into() + .map_err(|e: zbus::zvariant::Error| e.to_string())?; + let uri = uris.first().ok_or("no file in the portal's answer")?; + let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?; + let filter = results.get("current_filter").and_then(|v| { + let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?; + Some(label) + }); + Ok(Some(Saved { path, filter })) +} + +/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg`; `None` for a file elsewhere (`file://host/`) +/// or another scheme. +fn file_uri_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let rest = rest.strip_prefix("localhost").unwrap_or(rest); + if !rest.starts_with('/') { + return None; + } + let mut out = Vec::with_capacity(rest.len()); + let mut rest = rest.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let hex = tail.get(..2).filter(|h| b == b'%' && h.iter().all(u8::is_ascii_hexdigit)); + match hex.and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) { + Some(decoded) => { + out.push(decoded); + rest = tail.get(2..).unwrap_or_default(); + } + None => { + out.push(b); + rest = tail; + } + } + } + // `show_dialog` makes rfd's path a string with `to_string_lossy` too. + Some(String::from_utf8_lossy(&out).into_owned()) +} + +/// `saved` as the answer of the save dialog for `request`: the path, then the first extension of +/// the file type picked, found by its label. Empty when the dialog was canceled. +fn pick_answer(saved: Option, request: &SaveRequest) -> Vec { + let Some(saved) = saved else { return Vec::new() }; + let picked = saved.filter.and_then(|label| request.filters.iter().find(|(l, _)| *l == label)).and_then(|(_, exts)| exts.first().cloned()); + std::iter::once(saved.path).chain(picked).collect() +} + +/// The portal's identifier of an X11 window. +fn x11_parent(window: impl std::fmt::LowerHex) -> String { + format!("x11:{window:x}") +} + +/// Show the portal's save dialog for `pick` over `window` and wait for it → the answer (see the +/// module's comment). `None` when `pick` offers fewer than two file types, or the portal couldn't +/// show the dialog; rfd shows it then. +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] +pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option> { + if pick.filters.len() < 2 { + return None; + } + // The export stays until the dialog has answered: the handle is valid while it lives. + let (parent, _export) = window.map(parent).unwrap_or_default(); + let request = SaveRequest::new(pick, parent); + let saved = zbus::blocking::Connection::session().map_err(|e| e.to_string()).and_then(|conn| save_file(&conn, &request)); + match saved { + Ok(saved) => Some(pick_answer(saved, &request)), + Err(e) => { + log::info!("the portal's save dialog failed ({e}): rfd shows it"); + None + } + } +} + +/// Ask the portal on `conn` for the save dialog of `request` and wait for its answer. +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] +fn save_file(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result, String> { + use std::sync::atomic::{AtomicU32, Ordering}; + + use zbus::blocking::Proxy; + use zbus::zvariant::OwnedObjectPath; + + const DESKTOP: &str = "org.freedesktop.portal.Desktop"; + const REQUEST: &str = "org.freedesktop.portal.Request"; + static NEXT: AtomicU32 = AtomicU32::new(0); + let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_"); + let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)); + let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); + // Listen before asking, so that an answer that comes at once is not missed. + let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?; + let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?; + // The title is empty, as rfd sends it for a save. + let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?; + // A portal older than version 0.9 ignores `handle_token` and answers on the request path it + // returns. + let reply = if handle.as_str() == path { + replies.next() + } else { + Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next() + } + .ok_or("the save dialog closed without an answer")?; + let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| e.to_string())?; + answer(code, &results) +} + +/// The portal's identifier of `window` when it is an X11 window. +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] +fn x11_window(window: &winit::window::Window) -> Option { + use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; + match window.window_handle().ok()?.as_raw() { + RawWindowHandle::Xlib(h) => Some(x11_parent(h.window)), + RawWindowHandle::Xcb(h) => Some(x11_parent(h.window.get())), + _ => None, + } +} + +/// The portal's identifier of `window`, and on Wayland the export that keeps it valid; empty when +/// the window has neither an X11 id nor a Wayland handle. +#[cfg(target_os = "linux")] +fn parent(window: &winit::window::Window) -> (String, Option) { + if let Some(id) = x11_window(window) { + return (id, None); + } + match wayland::export(window) { + Some(export) => (format!("wayland:{}", export.handle), Some(export)), + None => (String::new(), None), + } +} + +/// The portal's identifier of `window`: its X11 id, else empty (no Wayland export is made off +/// Linux). +#[cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] +fn parent(window: &winit::window::Window) -> (String, Option<()>) { + (x11_window(window).unwrap_or_default(), None) +} + +/// The Wayland window's handle for the portal: an xdg-foreign v2 export of its surface +/// (), made as rfd's `window_identifier` +/// makes it. +#[cfg(target_os = "linux")] +mod wayland { + use wayland_client::globals::{GlobalListContents, registry_queue_init}; + use wayland_client::protocol::wl_registry::{self, WlRegistry}; + use wayland_client::protocol::wl_surface::WlSurface; + use wayland_client::{Connection, Dispatch, Proxy, QueueHandle}; + use wayland_protocols::xdg::foreign::zv2::client::zxdg_exported_v2::{self, ZxdgExportedV2}; + use wayland_protocols::xdg::foreign::zv2::client::zxdg_exporter_v2::{self, ZxdgExporterV2}; + + /// A window exported for the portal: `handle` names it until this is dropped. + pub struct Export { + pub handle: String, + exported: ZxdgExportedV2, + conn: Connection, + } + + impl Drop for Export { + fn drop(&mut self) { + self.exported.destroy(); + let _ = self.conn.flush(); + } + } + + /// The events the export waits for. + #[derive(Default)] + struct State { + handle: Option, + } + + impl Dispatch for State { + fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle) {} + } + + impl Dispatch for State { + fn event(_: &mut Self, _: &ZxdgExporterV2, _: zxdg_exporter_v2::Event, _: &(), _: &Connection, _: &QueueHandle) {} + } + + impl Dispatch for State { + fn event(state: &mut Self, _: &ZxdgExportedV2, event: zxdg_exported_v2::Event, _: &(), _: &Connection, _: &QueueHandle) { + if let zxdg_exported_v2::Event::Handle { handle } = event { + state.handle = Some(handle); + } + } + } + + /// Export `window`'s surface on the window's own Wayland connection, with an event queue of its + /// own → `None` when the compositor has no xdg-foreign v2 or answers no handle. + pub fn export(window: &winit::window::Window) -> Option { + let (conn, surface) = surface(window)?; + let (globals, mut queue) = registry_queue_init::(&conn).ok()?; + let qh = queue.handle(); + let exporter: ZxdgExporterV2 = globals.bind(&qh, 1..=1, ()).ok()?; + let exported = exporter.export_toplevel(&surface, &qh, ()); + let mut state = State::default(); + let answered = queue.roundtrip(&mut state); + exporter.destroy(); + match (answered, state.handle) { + (Ok(_), Some(handle)) => Some(Export { handle, exported, conn }), + _ => { + exported.destroy(); + let _ = conn.flush(); + log::info!("the compositor gave no xdg-foreign handle: the save dialog has no parent window"); + None + } + } + } + + /// `window`'s Wayland connection and surface, from its raw handles. + #[allow(unsafe_code)] + fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> { + use wayland_backend::sys::client::{Backend, ObjectId}; + use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle}; + let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) = + (window.window_handle().ok()?.as_raw(), window.display_handle().ok()?.as_raw()) + else { + return None; + }; + // SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller + // holds it until the export is dropped); a backend made from a foreign display does not + // close it when dropped. + let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) }; + let conn = Connection::from_backend(backend); + // SAFETY: the pointer is the window's live `wl_surface` proxy on that display. + let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), w.surface.as_ptr().cast()) }.ok()?; + let surface = WlSurface::from_id(&conn, id).ok()?; + Some((conn, surface)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> SaveRequest { + let pick = FilePick { + name: "Untitled-1.vectorcraft".into(), + folder: Some("/home/me/Art".into()), + filters: vec![("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF-compatible .ai", &["ai"])], + }; + SaveRequest::new(&pick, "x11:3a0000f".into()) + } + + /// The dialog starts on the first file type (the document's format) and shows the suggested + /// name in its folder. + #[test] + fn the_request_names_the_file_type_the_dialog_starts_on() { + let request = request(); + let o = options(&request, "t1"); + assert_eq!(o.get("current_filter"), Some(&Value::from(("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())])))); + let filters = vec![ + ("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]), + ("SVG".to_string(), vec![(0u32, "*.svg".to_string())]), + ("PDF-compatible .ai".to_string(), vec![(0u32, "*.ai".to_string())]), + ]; + assert_eq!(o.get("filters"), Some(&Value::from(filters))); + assert_eq!(o.get("current_name"), Some(&Value::from("Untitled-1.vectorcraft"))); + assert_eq!(o.get("current_folder"), Some(&Value::from(b"/home/me/Art\0".to_vec()))); + assert_eq!(o.get("handle_token"), Some(&Value::from("t1"))); + let bare = SaveRequest { name: String::new(), folder: None, filters: Vec::new(), ..request }; + let o = options(&bare, "t2"); + assert_eq!(o.keys().copied().collect::>(), ["handle_token"]); + } + + /// The portal's answer as a D-Bus dictionary: `uris` and `current_filter`. + fn results(uri: &str, filter: Option<&str>) -> HashMap { + let mut r = HashMap::new(); + r.insert("uris".to_string(), OwnedValue::try_from(Value::from(vec![uri.to_string()])).unwrap()); + if let Some(label) = filter { + let picked = Value::from((label.to_string(), vec![(0u32, "*.ai".to_string())])); + r.insert("current_filter".to_string(), OwnedValue::try_from(picked).unwrap()); + } + r + } + + /// The file type picked comes back with the path as typed, and its first extension follows + /// the path in the answer. + #[test] + fn the_answer_carries_the_file_type_picked() { + let request = request(); + let saved = answer(0, &results("file:///home/me/Art/poster", Some("PDF-compatible .ai"))).unwrap(); + assert_eq!(saved, Some(Saved { path: "/home/me/Art/poster".into(), filter: Some("PDF-compatible .ai".into()) })); + assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]); + // A portal that names no file type, or one the dialog did not offer: the path alone. + let saved = answer(0, &results("file:///home/me/Art/poster.svg", None)).unwrap(); + assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster.svg"]); + let saved = answer(0, &results("file:///home/me/Art/poster", Some("PNG"))).unwrap(); + assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]); + } + + /// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file + /// is an error, and rfd shows the dialog instead. + #[test] + fn a_canceled_dialog_answers_no_file() { + let request = request(); + for code in [1, 2] { + assert_eq!(answer(code, &HashMap::new()), Ok(None)); + assert!(pick_answer(None, &request).is_empty()); + } + assert!(answer(0, &HashMap::new()).is_err()); + assert!(answer(0, &results("sftp://host/poster.svg", Some("SVG"))).is_err()); + } + + #[test] + fn file_uris_become_local_paths() { + assert_eq!(file_uri_path("file:///home/me/Untitled-1.vectorcraft").as_deref(), Some("/home/me/Untitled-1.vectorcraft")); + assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.svg").as_deref(), Some("/home/me/A bé.svg")); + assert_eq!(file_uri_path("file://localhost/tmp/x.pdf").as_deref(), Some("/tmp/x.pdf")); + // A `%` without two hex digits after it stays as it is. + assert_eq!(file_uri_path("file:///tmp/100%+1%2").as_deref(), Some("/tmp/100%+1%2")); + assert_eq!(file_uri_path("file://host/x.svg"), None); + assert_eq!(file_uri_path("sftp://host/x.svg"), None); + } + + #[test] + fn x11_windows_are_named_by_their_id() { + assert_eq!(x11_parent(0x3a0000f), "x11:3a0000f"); + } + + /// What the mock portal answers to each `SaveFile` call: the response code, and the file and + /// the label of the file type "picked". + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + type Script = Vec<(u32, Option<(&'static str, &'static str)>)>; + + /// What each `SaveFile` call carried: `current_name` and the label of `current_filter`. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + type Seen = std::sync::Arc, Option)>>>; + + /// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` + /// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths` + /// it is a portal older than 0.9, which answers on a request path of its own. `None` when there + /// is no `dbus-daemon` to run. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { + use std::io::BufRead as _; + + use zbus::zvariant::OwnedObjectPath; + let mut daemon = std::process::Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(std::process::Stdio::piped()) + .spawn() + .ok()?; + let mut address = String::new(); + std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?; + let address = address.trim().to_string(); + let server = + zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?; + let seen = Seen::default(); + let log = seen.clone(); + std::thread::spawn(move || { + let mut script = script.into_iter(); + for msg in zbus::blocking::MessageIterator::from(&server) { + let Ok(msg) = msg else { break }; + let header = msg.header(); + if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") { + continue; + } + let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap)>() else { break }; + let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok()); + let filter = options.get("current_filter").and_then(|v| { + let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?; + Some(label) + }); + let calls = log.lock().map(|mut l| { + l.push((text("current_name"), filter)); + l.len() + }); + let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break }; + let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() }; + let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); + let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break }; + if server.reply(&header, &handle).is_err() { + break; + } + if own_paths { + // As a person answers the dialog: after the caller listened on the path returned. + std::thread::sleep(std::time::Duration::from_millis(300)); + } + let (code, pick) = script.next().unwrap_or((2, None)); + let mut results: HashMap<&str, Value<'_>> = HashMap::new(); + if let Some((uri, label)) = pick { + results.insert("uris", Value::from(vec![uri.to_string()])); + results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())]))); + } + let to = header.sender().cloned().map(zbus::names::BusName::from); + if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() { + break; + } + } + }); + let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?; + Some((client, seen, daemon)) + } + + /// Through a real D-Bus: the call starts the dialog on the first file type with the suggested + /// name, and the answer names the file and the type picked; Cancel answers no file. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() { + let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)]; + let Some((conn, seen, mut daemon)) = mock_portal(script, false) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let request = request(); + let saved = save_file(&conn, &request).unwrap(); + assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]); + assert_eq!(save_file(&conn, &request), Ok(None)); + let seen = seen.lock().map(|s| s.clone()).unwrap_or_default(); + assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone())))); + let _ = daemon.kill(); + } + + /// A portal older than 0.9 answers on the request path it returns, and the answer is awaited + /// there. + #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] + #[test] + fn the_answer_is_awaited_on_the_path_the_portal_returns() { + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let request = request(); + assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]); + let _ = daemon.kill(); + } + + /// The labels go to the dialog in the interface's language, which is how the answer is matched. + #[test] + fn the_file_types_are_labeled_as_the_other_dialogs_label_them() { + let request = request(); + let labels: Vec<&str> = request.filters.iter().map(|(l, _)| l.as_str()).collect(); + let lang = vectorcraft_ui_egui::i18n::current(); + let expected: Vec<&str> = + ["VectorCraft", "SVG", "PDF-compatible .ai"].iter().map(|l| vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", l)).collect(); + assert_eq!(labels, expected); + assert_eq!(request.parent, "x11:3a0000f"); + } +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index fa3e33b4c..b41ffd0c4 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -307,21 +307,22 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: /// typed without one). fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result { let ext = fileio::extension(&pick.name); - pick_finished(app, pick, |picked| with_extension(picked, &ext, |_| true)) + pick_finished(app, pick, |picked, _| with_extension(picked, &ext, |_| true)) } /// The suggested name made into a path by `finish` on the web (a download), else the save panel's -/// choice made into one. When `finish` changed the choice (an extension added) and a file is -/// there, which the panel did not ask about, a question asks before replacing it +/// choice made into one, with the extension of the file type picked in the panel when it reports +/// one ([`crate::picks::PickAnswer`]). When `finish` changed the choice (an extension added) and a +/// file is there, which the panel did not ask about, a question asks before replacing it /// ([`crate::picks::ask_to_replace`]) and this answers "cancelled"; the question's OK runs what /// asked again. -fn pick_finished(app: &mut VectorcraftApp, pick: &FilePick, finish: impl Fn(&str) -> String) -> Result { +fn pick_finished(app: &mut VectorcraftApp, pick: &FilePick, finish: impl Fn(&str, Option<&str>) -> String) -> Result { if is_web(app) { - return Ok(finish(&pick.name)); + return Ok(finish(&pick.name, None)); } - let picked = crate::picks::save(app, pick).ok_or("cancelled")?; - let path = finish(&picked); - if path != picked && crate::picks::ask_to_replace(app, crate::picks::PickRequest::Save(pick.clone()), &picked, &path) { + let (picked, file_type) = crate::picks::save_with_type(app, pick).ok_or("cancelled")?; + let path = finish(&picked, file_type.as_deref()); + if path != picked && crate::picks::ask_to_replace(app, crate::picks::PickRequest::Save(pick.clone()), &picked, file_type.as_deref(), &path) { return Err("cancelled".into()); } Ok(path) @@ -399,6 +400,23 @@ fn with_save_extension(path: &str, f: &Format, mode: SaveMode) -> String { with_extension(path, f.extensions[0], |g| formats.contains(&g.id)) } +/// [`with_save_extension`] for a save panel that reports the file type picked in it (`picked`, +/// that type's extension; the XDG portal's panel on Linux and the BSDs), which then takes `f`'s +/// place. A path that still ends in the extension of the name the panel suggested (`suggested`) +/// gets the picked type's extension instead. Without a picked type, or with one the panel did not +/// offer, [`with_save_extension`] with `f`. +fn with_picked_type(path: &str, suggested: &str, picked: Option<&str>, f: &Format, mode: SaveMode) -> String { + let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; + let Some(g) = picked.and_then(fileio::format).filter(|g| formats.contains(&g.id)) else { + return with_save_extension(path, f, mode); + }; + let has = std::path::Path::new(path).extension(); + if has.is_some() && has == std::path::Path::new(suggested).extension() { + return std::path::Path::new(path).with_extension(g.extensions[0]).to_string_lossy().into_owned(); + } + with_save_extension(path, g, mode) +} + /// Write every file of an export (one per artboard, linked images) for the destination `path`. /// → the export's own files (without the linked images). fn write_encoded(write: Writer, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result, String> { @@ -445,7 +463,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo _ => fileio::save_filters(first.format.id), }; let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters }; - let picked = pick_finished(app, &pick, |picked| with_save_extension(picked, first.format, mode))?; + let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?; // The picked file type wins over a `format` param. let mut q = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = q.as_object_mut() { diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 431ee0912..3d6bcf04a 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -37,7 +37,9 @@ pub enum PickRequest { Folder, } -/// The paths a dialog shown off the UI thread picked (none: cancelled). +/// The paths a dialog shown off the UI thread picked (none: cancelled). A save dialog that reports +/// which of its file types was picked (the XDG portal's on Linux and the BSDs) answers that type's +/// first extension after the path. pub type PickAnswer = Vec; /// Shows a dialog off the UI thread; its answer arrives on the receiver. `None` when it can't, and @@ -232,6 +234,13 @@ pub(crate) fn save(app: &mut VectorcraftApp, pick: &FilePick) -> Option ask(app, PickRequest::Save(pick.clone()))?.into_iter().next() } +/// A path from a save dialog for `pick`, and the extension of the file type picked in it when the +/// dialog reports one ([`PickAnswer`]). +pub(crate) fn save_with_type(app: &mut VectorcraftApp, pick: &FilePick) -> Option<(String, Option)> { + let mut answer = ask(app, PickRequest::Save(pick.clone()))?.into_iter(); + Some((answer.next()?, answer.next())) +} + /// A folder from the folder picker. pub(crate) fn folder(app: &mut VectorcraftApp) -> Option { ask(app, PickRequest::Folder)?.into_iter().next() @@ -355,19 +364,21 @@ fn on_document(app: &mut VectorcraftApp, doc: Option) -> bool { true } -/// `picked`, a save dialog's answer to `request`, names `path`, another file, once the app gave it +/// `picked`, a save dialog's answer to `request` (with `file_type`, the extension of the file type +/// picked in it when the dialog reports one), names `path`, another file, once the app gave it /// an extension: the dialog checked only `picked` for an existing file, not `path`. When a file is /// at `path`, ask before replacing it, over the dialog open now → true while the question is open /// (nothing is written meanwhile). OK runs what asked again with the same answer, and `path` is /// written then ([`replace`]); Cancel writes nothing. Nothing is asked while that OK runs what /// asked, or outside an entry, which nothing could run again. -pub(crate) fn ask_to_replace(app: &mut VectorcraftApp, request: PickRequest, picked: &str, path: &str) -> bool { +pub(crate) fn ask_to_replace(app: &mut VectorcraftApp, request: PickRequest, picked: &str, file_type: Option<&str>, path: &str) -> bool { if app.picks.replace_ok.as_deref() == Some(path) || !app.services.exists.as_ref().is_some_and(|exists| exists(path)) { return false; } let Some(entry) = app.picks.entry.clone() else { return false }; let doc = entry.document(app); - app.picks.replacing = Some(Replacing { request, answer: vec![picked.to_string()], path: path.to_string(), entry, doc }); + let answer = std::iter::once(picked).chain(file_type).map(str::to_string).collect(); + app.picks.replacing = Some(Replacing { request, answer, path: path.to_string(), entry, doc }); crate::dialogs::confirm::ask_replace(app, path, picked); true } diff --git a/crates/ui-egui/src/tests_saveext.rs b/crates/ui-egui/src/tests_saveext.rs index c989f06fa..57467695e 100644 --- a/crates/ui-egui/src/tests_saveext.rs +++ b/crates/ui-egui/src/tests_saveext.rs @@ -115,6 +115,91 @@ fn saves_add_the_format_extension_to_a_bare_name() { assert_eq!(last(&w), "/tmp/as.pdf"); } +/// An app with a document whose save dialogs show off the UI thread, as on Linux. +fn portal_app() -> (VectorcraftApp, crate::tests_picks::Shown, crate::tests_picks::Written) { + let mut services = Services::default(); + let (shown, written) = crate::tests_picks::off_the_ui_thread(&mut services); + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + (app, shown, written) +} + +/// Run `cmd`, whose save dialog answers `answer` (the path, then the extension of the file type +/// picked, as the Linux portal's dialog answers) → the path the save goes on with: the one written, +/// or the one the dialog that opens next holds (the options of SVG, PDF, and of native and .ai +/// files on Save As; the question whether to replace a file). +fn saved_from(app: &mut VectorcraftApp, shown: &crate::tests_picks::Shown, written: &Written, cmd: &str, answer: &[&str]) -> String { + written.borrow_mut().clear(); + assert!(app.run(cmd, json!({})).is_err(), "{cmd} waits for its dialog"); + shown.answer(answer); + crate::picks::poll(app, &egui::Context::default()); + match app.ui.dialog.as_ref() { + Some(d) => d.str("path"), + None => last(written), + } +} + +/// The file type picked in a save dialog that reports it decides the format (#2679): a bare name, or +/// one with the suggested name's extension, gets the picked type's extension; an extension typed for +/// another save format stays as typed, and any other gets the picked type's added. +#[test] +fn the_file_type_picked_in_the_save_dialog_decides_the_format() { + for (answer, expected) in [ + (&["/art/poster", "ai"][..], "/art/poster.ai"), + (&["/art/Untitled-1.vectorcraft", "ai"], "/art/Untitled-1.ai"), + (&["/art/poster.SVG", "ai"], "/art/poster.SVG"), + (&["/art/poster.foo", "ai"], "/art/poster.foo.ai"), + (&["/art/poster.ai", "vectorcraft"], "/art/poster.ai"), + (&["/art/poster", "svg"], "/art/poster.svg"), + // A dialog that doesn't say, as on Windows and macOS: the default format's extension. + (&["/art/poster"], "/art/poster.vectorcraft"), + ] { + let (mut app, shown, written) = portal_app(); + assert_eq!(saved_from(&mut app, &shown, &written, "file.save", answer), expected, "{answer:?}"); + } +} + +/// Save As to the type picked asks for that format's options: the .ai options (Version first), +/// not VectorCraft's. +#[test] +fn save_as_asks_for_the_options_of_the_file_type_picked() { + let (mut app, shown, written) = portal_app(); + assert_eq!(saved_from(&mut app, &shown, &written, "file.saveAs", &["/art/poster", "ai"]), "/art/poster.ai"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("format")), (dialogs::save_options::KIND, "ai".to_string())); +} + +/// A document saved as .ai lists .ai first: VectorCraft picked with a bare name or with the +/// suggested name saves a native file. Save a Copy and Save as Template follow the type picked too. +#[test] +fn a_document_in_another_format_saves_as_the_type_picked() { + let (mut app, shown, written) = portal_app(); + app.run("file.saveAs", json!({"path": "/art/a.ai", "options": {}})).unwrap(); + assert_eq!(*written.borrow(), ["/art/a.ai"]); + assert_eq!(saved_from(&mut app, &shown, &written, "file.saveAs", &["/art/poster", "vectorcraft"]), "/art/poster.vectorcraft"); + dialogs::cancel(&mut app); + assert_eq!(saved_from(&mut app, &shown, &written, "file.saveAs", &["/art/a.ai", "vectorcraft"]), "/art/a.vectorcraft"); + dialogs::cancel(&mut app); + assert_eq!(saved_from(&mut app, &shown, &written, "file.saveCopy", &["/art/copy", "svg"]), "/art/copy.svg"); + dialogs::cancel(&mut app); + assert_eq!(saved_from(&mut app, &shown, &written, "file.saveAsTemplate", &["/art/card", "ait"]), "/art/card.ait"); +} + +/// The name the type picked gives names a file that is there, which the dialog did not ask about: +/// the save asks first, and the question's OK writes that file in the picked format. +#[test] +fn a_name_given_the_picked_types_extension_asks_before_replacing() { + let (mut app, shown, written) = portal_app(); + app.services.exists = Some(Box::new(|p: &str| p == "/art/poster.ai")); + assert_eq!(saved_from(&mut app, &shown, &written, "file.save", &["/art/poster", "ai"]), "/art/poster.ai"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE)); + assert!(written.borrow().is_empty()); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(*written.borrow(), ["/art/poster.ai"]); + assert_eq!(app.session.active().unwrap().format, "ai"); +} + #[test] fn exports_add_the_format_extension_to_a_bare_name() { let (mut app, w) = saving_app("/tmp/art"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 97cae5ccd..c2ce648fc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -690,6 +690,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing). - macOS: the save sheet of Save As, Save a Copy, Save as Template and the first Save of a new document has a Format menu that lists each format the save can write, with the suggested file's format selected. Choosing a format changes the extension in the name field, and a name typed without an extension gets the chosen format's. A name typed with another listed extension is saved in that format. The menu is set up before the sheet opens; a save panel with one file type is shown by rfd as before (#866, `apps/vectorcraft/src/mac_save_format.rs`). + - Linux and the BSDs: the save dialog of Save As, Save a Copy, Save as Template and the first Save of a new document is asked of the desktop portal, starting on the suggested file's type, and the file type picked in it decides the format. A name typed without an extension, or with the suggested file's, gets the picked type's extension; a name typed with another save format's extension (for Save as Template, a template format's) is saved in that format, and any other extension gets the picked type's added. rfd shows the dialog when the portal can't (#2679, `apps/vectorcraft/src/portal_save.rs`). - Save as Template writes a PDF-compatible `.ait` as well as a `.vctemplate`, which stays the default: the save panel and the web build's Save as Template dialog list both, and New from Template opens the `.ait` as an untitled document with its layers and art (`file.saveAsTemplate {format: "ait"}`, or a path ending in `.ait`). Save as Template always writes a file named with its template type's extension: `file.saveAsTemplate` saves a path ending in another format's extension (`card.png`) as `card.vctemplate` (or `card.ait`), the desktop save panel adds `.vctemplate` to a name typed with a Save As format's extension (`card.pdf.vctemplate`), and `file.saveAs` and `document.save` refuse `ait` with an error that names `file.saveAsTemplate`. A `.ait` is PDF-compatible only: `aiVersion: "7"` is an error for it, and the web build's dialog shows no Version row for it. Save as Template always writes a file named with its template type's extension: `file.saveAsTemplate` saves a path ending in another format's extension (`card.png`) as `card.vctemplate` (or `card.ait`), the desktop save panel adds `.vctemplate` to a name typed with a Save As format's extension (`card.pdf.vctemplate`), and `file.saveAs` and `document.save` refuse `ait` with an error that names `file.saveAsTemplate`.