Files
IronStorage/apps/desktop/src/folder_picker.rs
Chili Palmer 189ae7721e
All checks were successful
Tagged release / prepare-release (push) Successful in 4s
Tagged release / build-unix (push) Successful in 49m4s
Tagged release / build-windows (push) Successful in 17m53s
Tagged release / publish-release (push) Successful in 3s
Build tagged releases on Gitea (#79)
2026-08-12 22:27:50 +02:00

182 lines
5.8 KiB
Rust

//! 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<PathBuf>) -> Result<Option<PathBuf>, 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<Option<SecretBytes>, 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<Option<PathBuf>, 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<Option<PathBuf>, 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<Option<PathBuf>, 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<PathBuf>) -> Result<Option<PathBuf>, 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<Option<SecretBytes>, 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<Option<PathBuf>, String> {
pick_file("Import KeePass Database", "Import").await
}
#[cfg(target_os = "linux")]
pub async fn pick_key_file() -> Result<Option<PathBuf>, String> {
pick_file("Select KeePass Key File", "Select").await
}
#[cfg(target_os = "linux")]
async fn pick_file(title: &str, accept_label: &str) -> Result<Option<PathBuf>, 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<PathBuf, String> {
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());
}
}