//! Platform folder pickers without helper processes. use std::path::PathBuf; use ironstorage::repository::SecretBytes; #[cfg(any(target_os = "macos", target_os = "windows"))] pub async fn pick_folder(initial: Option) -> Result, String> { let mut dialog = rfd::AsyncFileDialog::new().set_title("Open Password Store"); if let Some(initial) = initial.filter(|path| path.is_dir()) { dialog = dialog.set_directory(initial); } Ok(dialog .pick_folder() .await .map(|folder| folder.path().to_owned())) } #[cfg(any(target_os = "macos", target_os = "windows"))] pub async fn pick_qr_image() -> Result, String> { let selected = rfd::AsyncFileDialog::new() .set_title("Import OTP QR Image") .add_filter("Image", &["png", "jpg", "jpeg", "gif"]) .pick_file() .await; selected .map(|file| { std::fs::read(file.path()) .map(SecretBytes::new) .map_err(|error| error.to_string()) }) .transpose() } #[cfg(any(target_os = "macos", target_os = "windows"))] pub async fn pick_kdbx_file() -> Result, String> { pick_file("Import KeePass Database", "KeePass database", &["kdbx"]).await } #[cfg(any(target_os = "macos", target_os = "windows"))] pub async fn pick_key_file() -> Result, String> { pick_file("Select KeePass Key File", "Key file", &["key", "keyx"]).await } #[cfg(any(target_os = "macos", target_os = "windows"))] async fn pick_file( title: &str, filter_name: &str, extensions: &[&str], ) -> Result, String> { Ok(rfd::AsyncFileDialog::new() .set_title(title) .add_filter(filter_name, extensions) .pick_file() .await .map(|file| file.path().to_owned())) } #[cfg(target_os = "linux")] pub async fn pick_folder(initial: Option) -> Result, String> { use ashpd::{ PortalError, desktop::{ResponseError, file_chooser::SelectedFiles}, }; let mut request = SelectedFiles::open_file() .title("Open Password Store") .accept_label("Open") .modal(true) .multiple(false) .directory(true); if let Some(initial) = initial.filter(|path| path.is_dir()) { request = request .current_folder(initial) .map_err(|error| error.to_string())?; } let response = request.send().await.and_then(|request| request.response()); let selected = match response { Ok(selected) => selected, Err(ashpd::Error::Response(ResponseError::Cancelled)) | Err(ashpd::Error::Portal(PortalError::Cancelled(_))) => return Ok(None), Err(error) => return Err(error.to_string()), }; selected .uris() .first() .ok_or_else(|| "folder portal returned no selection".to_owned()) .and_then(|uri| file_uri_path(uri.as_str())) .map(Some) } #[cfg(target_os = "linux")] pub async fn pick_qr_image() -> Result, String> { use ashpd::{ PortalError, desktop::{ResponseError, file_chooser::SelectedFiles}, }; let response = SelectedFiles::open_file() .title("Import OTP QR Image") .accept_label("Import") .modal(true) .multiple(false) .send() .await .and_then(|request| request.response()); let selected = match response { Ok(selected) => selected, Err(ashpd::Error::Response(ResponseError::Cancelled)) | Err(ashpd::Error::Portal(PortalError::Cancelled(_))) => return Ok(None), Err(error) => return Err(error.to_string()), }; let Some(uri) = selected.uris().first() else { return Err("file portal returned no selection".to_owned()); }; let path = file_uri_path(uri.as_str())?; std::fs::read(path) .map(SecretBytes::new) .map(Some) .map_err(|error| error.to_string()) } #[cfg(target_os = "linux")] pub async fn pick_kdbx_file() -> Result, String> { pick_file("Import KeePass Database", "Import").await } #[cfg(target_os = "linux")] pub async fn pick_key_file() -> Result, String> { pick_file("Select KeePass Key File", "Select").await } #[cfg(target_os = "linux")] async fn pick_file(title: &str, accept_label: &str) -> Result, String> { use ashpd::{ PortalError, desktop::{ResponseError, file_chooser::SelectedFiles}, }; let response = SelectedFiles::open_file() .title(title) .accept_label(accept_label) .modal(true) .multiple(false) .send() .await .and_then(|request| request.response()); let selected = match response { Ok(selected) => selected, Err(ashpd::Error::Response(ResponseError::Cancelled)) | Err(ashpd::Error::Portal(PortalError::Cancelled(_))) => return Ok(None), Err(error) => return Err(error.to_string()), }; selected .uris() .first() .ok_or_else(|| "file portal returned no selection".to_owned()) .and_then(|uri| file_uri_path(uri.as_str())) .map(Some) } #[cfg(target_os = "linux")] fn file_uri_path(uri: &str) -> Result { let uri = url::Url::parse(uri).map_err(|_| "folder portal returned an invalid URI")?; uri.to_file_path() .map_err(|()| "folder portal returned a non-file URI".to_owned()) } #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; #[test] fn portal_file_uris_are_decoded_and_other_schemes_are_rejected() { assert_eq!( file_uri_path("file:///tmp/password%20store").expect("file URI"), PathBuf::from("/tmp/password store") ); assert!(file_uri_path("https://example.test/store").is_err()); } }