Files
vectorcraft/apps/vectorcraft/src/portal_save.rs
T
dotCooCoo 6c26233a63 Clipboard: quitting on Linux takes every window off screen before the clipboard wait
Quitting can wait up to 27 s for the clipboard handles to let go before
eframe closes the windows. winit cannot hide a native Wayland window, so
the main window stayed on screen for that wait, and the X11 windows of
floating panels and documents were not hidden at all.

- A native Wayland main window is unmapped by attaching no buffer to its
  surface. The window and its connection stay open for the Wayland
  clipboard. An X11 main window, XWayland included, is hidden as before.
- On Linux the X11 windows of floating panels and documents are recorded
  as eframe creates and drops them (viewport hooks), and quitting unmaps
  them through a second X11 connection before the wait.
- The new module apps/vectorcraft/src/quit_windows.rs replaces
  app::hide_before_teardown, with tests of which windows are hidden or
  unmapped. portal_save's Wayland surface helper is shared with it.
2026-11-13 22:02:56 -08:00

659 lines
33 KiB
Rust

//! 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
//! (<https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html>).
//! 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.
//! rfd also shows the dialog when the portal leaves the bus before it answers.
//!
//! The dialog's parent is the window: `x11:<window id>` 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<String>);
/// 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<String>,
/// The file types, labeled in the interface's language; the dialog starts on the first.
filters: Vec<Filter>,
}
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<String>,
}
/// `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::<Vec<_>>()));
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<String, OwnedValue>) -> Result<Option<Saved>, String> {
if code != 0 {
return Ok(None);
}
let uris: Vec<String> = 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}"))?;
// A name that isn't UTF-8 can't be written as picked: replacing its bytes would save over (and
// ask to replace) another file. The dialog ends without a file instead.
let Ok(path) = String::from_utf8(path) else {
log::warn!("the portal's save dialog picked a file name that isn't UTF-8: nothing is saved");
return Ok(None);
};
let filter = results.get("current_filter").and_then(|v| filter_label(v));
Ok(Some(Saved { path, filter }))
}
/// The label of a file type as the portal sends it, a `(sa(us))` structure; `None` for a value of
/// another shape.
fn filter_label(value: &Value<'_>) -> Option<String> {
match value {
Value::Structure(s) => match s.fields() {
[Value::Str(label), Value::Array(globs)] if globs.element_signature() == "(us)" => Some(label.to_string()),
_ => None,
},
_ => None,
}
}
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg` (its bytes); `None` for a file elsewhere
/// (`file://host/`) or another scheme.
fn file_uri_path(uri: &str) -> Option<Vec<u8>> {
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;
}
}
}
Some(out)
}
/// `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<Saved>, request: &SaveRequest) -> Vec<String> {
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<Vec<String>> {
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<Option<Saved>, String> {
use std::sync::atomic::{AtomicU32, Ordering};
use zbus::blocking::Proxy;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
static NEXT: AtomicU32 = AtomicU32::new(0);
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
// Made before the call: a portal can send its answer before the call returns, and a portal
// that leaves the bus while its dialog is open ends the wait.
let responses = crate::portal_request::Responses::new(conn)?;
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 call = chooser.call_method("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
let reply = responses.wait(&call)?;
let (code, results): (u32, HashMap<String, OwnedValue>) = 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<String> {
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<wayland::Export>) {
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),
}
}
/// [`parent`] for another dialog of the portal (the screen color sampler, `linux_screen_color`):
/// the identifier stays valid until the second value is dropped.
#[cfg(target_os = "linux")]
pub(crate) fn exported_parent(window: &winit::window::Window) -> (String, impl Send + 'static) {
parent(window)
}
/// 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
/// (<https://wayland.app/protocols/xdg-foreign-unstable-v2>), made as rfd's `window_identifier`
/// makes it.
#[cfg(target_os = "linux")]
pub(crate) 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<String>,
}
impl Dispatch<WlRegistry, GlobalListContents> for State {
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
}
impl Dispatch<ZxdgExporterV2, ()> for State {
fn event(_: &mut Self, _: &ZxdgExporterV2, _: zxdg_exporter_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
}
impl Dispatch<ZxdgExportedV2, ()> for State {
fn event(state: &mut Self, _: &ZxdgExportedV2, event: zxdg_exported_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
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<Export> {
let (conn, surface) = surface(window)?;
let (globals, mut queue) = registry_queue_init::<State>(&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. The caller holds `window`
/// while it uses them (`quit_windows` unmaps the main window's surface through them).
#[allow(unsafe_code)]
pub(crate) 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 while it uses the connection, and the export holds it until it 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![
FilePick::filter("VectorCraft", &["vectorcraft"]),
FilePick::filter("SVG", &["svg"]),
FilePick::filter("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::<Vec<_>>(), ["handle_token"]);
}
/// The portal's answer as a D-Bus dictionary: `uris` and `current_filter`.
fn results(uri: &str, filter: Option<&str>) -> HashMap<String, OwnedValue> {
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"]);
}
/// A `current_filter` of another shape than `(sa(us))` (a structure with fewer, other or more
/// fields, a string) gives the path alone; `uris` of another shape is an error.
#[test]
fn an_answer_of_another_shape_is_not_read() {
let malformed = [
Value::from(("SVG".to_string(),)),
Value::from(("SVG".to_string(), vec![(0u32,)])),
Value::from(("SVG".to_string(), vec![("*.svg".to_string(), 0u32)])),
Value::from((1u32, vec![(0u32, "*.svg".to_string())])),
Value::from(("SVG".to_string(), vec![(0u32, "*.svg".to_string())], 1u32)),
Value::from("SVG"),
];
for filter in malformed {
let mut r = results("file:///tmp/card", None);
r.insert("current_filter".to_string(), OwnedValue::try_from(filter).unwrap());
assert_eq!(answer(0, &r), Ok(Some(Saved { path: "/tmp/card".into(), filter: None })));
}
let malformed = [
Value::from(vec![1u32]),
Value::from(vec![("file:///tmp/card".to_string(),)]),
Value::from(Vec::<String>::new()),
Value::from("file:///tmp/card"),
];
for uris in malformed {
let mut r = HashMap::new();
r.insert("uris".to_string(), OwnedValue::try_from(uris).unwrap());
assert!(answer(0, &r).is_err());
}
}
/// 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() {
let file_uri_path = |uri: &str| super::file_uri_path(uri).map(|b| String::from_utf8(b).unwrap());
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);
// Not UTF-8: the bytes as they are, which `answer` refuses rather than changing them.
assert_eq!(super::file_uri_path("file:///tmp/a%FF.svg"), Some(b"/tmp/a\xff.svg".to_vec()));
assert!(matches!(answer(0, &results("file:///tmp/a%FF.svg", None)), Ok(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<std::sync::Mutex<Vec<(Option<String>, Option<String>)>>>;
/// How the mock portal answers `SaveFile`.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[derive(Clone, Copy, PartialEq, Eq)]
enum Portal {
/// On the request path that `handle_token` names, after its reply.
Current,
/// As a portal older than 0.9: on a request path of its own, 300 ms after its reply, as a
/// person answers the dialog.
Old,
/// As `Old`, with the `Response` sent before the reply that names its path.
OldEarly,
/// As `Current`, but it leaves the bus 300 ms after its reply without a `Response`, as a
/// portal that exits while its dialog is open.
Exits,
/// Leaves the bus right after its reply, without a `Response`.
ExitsAtOnce,
/// Sends its `Response` right after its reply, then leaves the bus.
AnswersAndExits,
}
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
/// that answers `SaveFile` from `script` as `portal` says, as PhotoCraft's portal tests run it.
/// `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, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, crate::portal_request::test_bus::Daemon)> {
use zbus::zvariant::OwnedObjectPath;
let (daemon, address) = crate::portal_request::test_bus::private_bus()?;
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();
// Made before the client exists: the connection drops a call that comes while no stream
// reads it.
let incoming = zbus::blocking::MessageIterator::from(&server);
std::thread::spawn(move || {
let mut script = script.into_iter();
for msg in incoming {
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<String, OwnedValue>)>() 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| filter_label(v));
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 = match portal {
Portal::Old | Portal::OldEarly => format!("old{}", calls.unwrap_or_default()),
Portal::Current | Portal::Exits | Portal::ExitsAtOnce | Portal::AnswersAndExits => 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 };
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);
let respond = || server.emit_signal(to.clone(), path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, &results));
if portal == Portal::OldEarly && respond().is_err() {
break;
}
if server.reply(&header, &handle).is_err() {
break;
}
if matches!(portal, Portal::Old | Portal::Exits) {
// As a person answers the dialog: after the caller has the path returned.
std::thread::sleep(std::time::Duration::from_millis(300));
}
if matches!(portal, Portal::Exits | Portal::ExitsAtOnce) {
break;
}
if portal != Portal::OldEarly && respond().is_err() {
break;
}
if portal == Portal::AnswersAndExits {
break;
}
}
// The portal leaves the bus: the bus sends `NameOwnerChanged` for its name.
let _ = server.close();
});
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
Some((client, seen, daemon))
}
/// [`save_file`] on `conn`, given 30 seconds to answer: an answer that is never awaited fails
/// the test instead of leaving it waiting.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn save_within(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
let (tx, rx) = std::sync::mpsc::channel();
let (conn, request) = (conn.clone(), request.clone());
std::thread::spawn(move || {
let _ = tx.send(save_file(&conn, &request));
});
rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the portal's answer was not awaited within 30 seconds")
}
/// 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, _daemon)) = mock_portal(script, Portal::Current) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
let saved = save_within(&conn, &request).unwrap();
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
assert_eq!(save_within(&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()))));
}
/// 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, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
}
/// A portal older than 0.9 can answer before its reply names the request path: the answer is
/// kept until the path is known, and the next dialog does not take it.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn an_answer_that_comes_before_the_path_is_kept() {
let script = vec![(0, Some(("file:///tmp/card", "SVG"))), (1, None)];
let Some((conn, _seen, _daemon)) = mock_portal(script, Portal::OldEarly) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
assert_eq!(save_within(&conn, &request), Ok(None));
}
/// A portal that leaves the bus after its reply without answering, while its dialog is open or
/// at once, ends the wait with an error, and rfd shows the dialog.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn a_portal_that_exits_during_the_dialog_ends_the_wait() {
for portal in [Portal::Exits, Portal::ExitsAtOnce] {
let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], portal) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_within(&conn, &request()), Err("the desktop portal disappeared while waiting for its answer".to_string()));
}
}
/// A portal that answers and then leaves the bus: the save reads the answer. Whether the answer
/// or the bus's report comes first depends on timing here; `portal_request`'s
/// `a_queued_answer_wins_over_the_portal_leaving` queues both before the wait.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn an_answer_sent_just_before_the_portal_exits_is_kept() {
let Some((conn, _seen, _daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::AnswersAndExits) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
}
/// 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");
}
}