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`.