use std::collections::HashMap; use std::os::{fd::AsFd as _, unix::ffi::OsStrExt as _}; use std::path::PathBuf; use tracing::{debug, error, warn}; use zbus::{Connection, ObjectServer, fdo::Result, zvariant}; use super::documents::DocumentsProxy; use super::request::{RESPONSE_SUCCESS, ReqHandler, ResultTransformer}; #[derive(Clone)] pub struct FileChooser { host: FileChooserProxy<'static>, docs: DocumentsProxy<'static>, guest_root: PathBuf, } impl FileChooser { pub async fn new( host_session_conn: &Connection, priv_conn: &Connection, guest_root: PathBuf, ) -> Result { let host = FileChooserProxy::builder(host_session_conn).build().await?; let docs = DocumentsProxy::builder(priv_conn).build().await?; Ok(FileChooser { host, docs, guest_root, }) } } #[zbus::interface( name = "org.freedesktop.portal.FileChooser", proxy( default_service = "org.freedesktop.portal.Desktop", default_path = "/org/freedesktop/portal/desktop" ) )] impl FileChooser { async fn open_file( &self, #[zbus(header)] hdr: zbus::message::Header<'_>, #[zbus(object_server)] server: &ObjectServer, #[zbus(connection)] conn: &zbus::Connection, parent_window: &str, title: &str, options: HashMap<&str, zvariant::Value<'_>>, ) -> Result { ReqHandler::prepare(&self.host, hdr, server, conn, &options) .with_transform(FileTransformer { docs: self.docs.clone(), guest_root: self.guest_root.clone(), for_save: false, persistent: true, directory: options.get_as("directory")?.unwrap_or(false), }) .perform(async || self.host.open_file(parent_window, title, options).await) .await } async fn save_file( &self, #[zbus(header)] hdr: zbus::message::Header<'_>, #[zbus(object_server)] server: &ObjectServer, #[zbus(connection)] conn: &zbus::Connection, parent_window: &str, title: &str, options: HashMap<&str, zvariant::Value<'_>>, ) -> Result { ReqHandler::prepare(&self.host, hdr, server, conn, &options) .with_transform(FileTransformer { docs: self.docs.clone(), guest_root: self.guest_root.clone(), for_save: true, persistent: true, directory: false, }) .perform(async || self.host.save_file(parent_window, title, options).await) .await } async fn save_files( &self, #[zbus(header)] hdr: zbus::message::Header<'_>, #[zbus(object_server)] server: &ObjectServer, #[zbus(connection)] conn: &zbus::Connection, parent_window: &str, title: &str, options: HashMap<&str, zvariant::Value<'_>>, ) -> Result { ReqHandler::prepare(&self.host, hdr, server, conn, &options) .with_transform(FileTransformer { docs: self.docs.clone(), guest_root: self.guest_root.clone(), for_save: true, persistent: true, directory: false, }) .perform(async || self.host.save_files(parent_window, title, options).await) .await } /// version property #[zbus(property, name = "version")] fn version(&self) -> Result { Ok(5) } } #[derive(Clone)] pub struct FileTransformer { pub docs: DocumentsProxy<'static>, pub guest_root: PathBuf, pub for_save: bool, pub persistent: bool, pub directory: bool, } // ref: send_response_in_thread_func // https://github.com/flatpak/xdg-desktop-portal/blob/d037b5c3f91b68ca208a9a41b6e18e6a3a659e05/src/file-chooser.c#L70C1-L70C29 impl ResultTransformer for FileTransformer { async fn apply<'a>( self, response: u32, mut results: HashMap<&'a str, zvariant::Value<'a>>, ) -> Result<(u32, HashMap<&'a str, zvariant::Value<'a>>)> { if response != RESPONSE_SUCCESS { debug!(?response, ?results, "non-success, not transforming"); return Ok((response, results)); } let guest_uris = results .get_required_as::("uris")? .into_iter() .flat_map(uri_to_path) .async_map(|u| self.add_path_as_doc(u)) .await .flatten() .map(|path| match url::Url::from_file_path(&path) { Ok(url) => Some(url.to_string()), Err(err) => { warn!(?err, ?path, "could not make url from returned path"); None } }) .flatten() .collect::>(); results.insert("uris", guest_uris.into()); Ok((response, results)) } } const REUSE_EXISTING: u32 = 1 << 0; const PERSISTENT: u32 = 1 << 1; const AS_NEEDED_BY_APP: u32 = 1 << 2; const DIRECTORY: u32 = 1 << 3; // ref: xdp_register_document // https://github.com/flatpak/xdg-desktop-portal/blob/10e712e06aa8eb9cd0e59c73c5be62ba53e981a4/src/xdp-documents.c#L71 impl FileTransformer { pub async fn add_path_as_doc(&self, path: PathBuf) -> Option { use rustix::fs::{Mode, OFlags}; let o_path_fd = match rustix::fs::open( if self.for_save { path.parent()? } else { &path }, OFlags::CLOEXEC | OFlags::PATH, Mode::empty(), ) { Ok(fd) => fd, Err(err) => { warn!(%err, ?path, "could not open path descriptor"); return None; } }; let flags = REUSE_EXISTING | AS_NEEDED_BY_APP | if self.persistent { PERSISTENT } else { 0 } | if self.directory { DIRECTORY } else { 0 }; // XXX: portal impl can return writable=false but host frontend does not pass that back.. // https://github.com/flatpak/xdg-desktop-portal/discussions/1763 let permissions = &["read", "write", "grant-permissions"][..]; let filename = path.file_name()?; debug!( ?path, ?filename, ?o_path_fd, ?flags, ?permissions, "adding path to doc portal" ); let app_id = ""; // host let doc_id = match if self.for_save { let filename_c = std::ffi::CString::new(filename.as_bytes()).ok()?; self.docs .add_named_full( o_path_fd.as_fd().into(), filename_c.as_bytes_with_nul(), flags, app_id, permissions, ) .await .map(|(doc_id, m)| (Some(doc_id), m)) } else { self.docs .add_full(&[o_path_fd.as_fd().into()], flags, app_id, permissions) .await .map(|(mut doc_ids, m)| (doc_ids.pop(), m)) } { Ok((Some(v), _)) => v, Ok((None, _)) => { warn!(?filename, "adding doc to portal gave no ids"); return None; } Err(err) => { warn!(?err, ?filename, "could not add doc to portal"); return None; } }; Some(self.guest_root.join(doc_id).join(filename)) } } fn uri_to_path(v: &zvariant::Value<'_>) -> Option { let url_str = match v.downcast_ref::() { Ok(sv) => sv, Err(err) => { warn!(%err, ?v, "option 'uris' contains non-string?"); return None; } }; let url = match url::Url::parse(url_str.as_str()) { Ok(u) => u, Err(err) => { warn!(%err, %url_str, "option 'uris' contains non-parseable uri"); return None; } }; if url.scheme() != "file" { warn!(%url, "skipping non-file uri"); return None; } Some(PathBuf::from(url.path())) } trait MapExt<'a> { fn get_as(&'a self, key: &'a str) -> Result> where T: TryFrom<&'a zvariant::Value<'a>>, >>::Error: std::fmt::Display; fn get_required_as(&'a self, key: &'a str) -> Result where T: TryFrom<&'a zvariant::Value<'a>>, >>::Error: std::fmt::Display, { self.get_as(key).and_then(|o| { o.ok_or_else(|| { error!(%key, "options get_as, missing"); zbus::fdo::Error::Failed(format!("option '{key}' missing")) }) }) } } impl<'a> MapExt<'a> for HashMap<&'a str, zvariant::Value<'a>> { fn get_as(&'a self, key: &str) -> Result> where T: TryFrom<&'a zvariant::Value<'a>>, >>::Error: std::fmt::Display, { self.get(key) .map(|v| { // inlined downcast_ref if let zvariant::Value::Value(v) = v { ::try_from(v) } else { ::try_from(v) } }) .transpose() .map_err(|err| { error!(%err, %key, "options get_as"); zbus::fdo::Error::Failed(format!("option '{key}' type mismatch")) }) } } trait IterAsyncExt: Iterator { async fn async_map(self, f: F) -> impl Iterator where Self: Sized, F: FnMut(Self::Item) -> FU, FU: Future, { futures::future::join_all(self.map(f)).await.into_iter() } } impl IterAsyncExt for T {}