Open configured vault folders

This commit is contained in:
2026-08-10 17:21:35 +02:00
parent c301d9a3df
commit 491d7b1557
11 changed files with 712 additions and 19 deletions

View File

@@ -18,5 +18,12 @@ zeroize.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
muda.workspace = true
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
rfd.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
ashpd.workspace = true
url.workspace = true
[dev-dependencies]
tempfile = "3"

View File

@@ -7,6 +7,7 @@ pub enum UiAction {
About,
Settings,
NewEntry,
OpenFolder,
OpenEntry,
Save,
CloseWindow,
@@ -34,6 +35,7 @@ impl UiAction {
Self::About => "about",
Self::Settings => "settings",
Self::NewEntry => "new-entry",
Self::OpenFolder => "open-folder",
Self::OpenEntry => "open-entry",
Self::Save => "save",
Self::CloseWindow => "close-window",
@@ -104,6 +106,7 @@ pub struct ActionContext {
pub editing: bool,
pub dirty: bool,
pub saving: bool,
pub switching_vault: bool,
pub focused_field: bool,
pub focused_sensitive: bool,
pub entry_path: bool,
@@ -127,7 +130,13 @@ pub const ACTIONS: &[ActionSpec] = &[
Some("⌘Q"),
),
spec(UiAction::NewEntry, MenuGroup::File, "New Entry", Some("⌘N")),
spec(UiAction::OpenEntry, MenuGroup::File, "Open", Some("⌘O")),
spec(
UiAction::OpenFolder,
MenuGroup::File,
"Open Folder…",
Some("⌘O"),
),
spec(UiAction::OpenEntry, MenuGroup::Entry, "Open Entry", None),
spec(UiAction::Save, MenuGroup::File, "Save", Some("⌘S")),
spec(
UiAction::CloseWindow,
@@ -221,20 +230,53 @@ pub fn enabled(action: UiAction, context: ActionContext) -> bool {
UiAction::About | UiAction::Settings | UiAction::Help => true,
// Entry creation is not valid until the dedicated workflow exists.
UiAction::NewEntry => false,
UiAction::OpenEntry => context.storage_ready && context.entry_path && !context.saving,
UiAction::Save => context.unlocked && context.editing && context.dirty && !context.saving,
UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => true,
UiAction::OpenFolder => {
context.storage_ready && !context.saving && !context.switching_vault
}
UiAction::OpenEntry => {
context.storage_ready
&& context.entry_path
&& !context.saving
&& !context.switching_vault
}
UiAction::Save => {
context.unlocked
&& context.editing
&& context.dirty
&& !context.saving
&& !context.switching_vault
}
UiAction::CloseWindow | UiAction::Quit => !context.switching_vault,
UiAction::Minimize => true,
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => false,
UiAction::CopyField => {
context.unlocked && context.document_open && !context.editing && context.focused_field
context.unlocked
&& context.document_open
&& !context.editing
&& !context.switching_vault
&& context.focused_field
}
UiAction::CopyEditedField => {
context.unlocked && context.editing && !context.switching_vault && context.focused_field
}
UiAction::CopyEditedField => context.unlocked && context.editing && context.focused_field,
UiAction::TogglePaneFocus => true,
UiAction::Refresh => context.storage_ready && !context.tree_loading,
UiAction::ReloadEntry => context.unlocked && context.document_open && !context.saving,
UiAction::EditEntry => context.unlocked && context.document_open && !context.editing,
UiAction::Refresh => {
context.storage_ready && !context.tree_loading && !context.switching_vault
}
UiAction::ReloadEntry => {
context.unlocked && context.document_open && !context.saving && !context.switching_vault
}
UiAction::EditEntry => {
context.unlocked
&& context.document_open
&& !context.editing
&& !context.switching_vault
}
UiAction::ToggleReveal => {
context.unlocked && context.document_open && context.focused_sensitive
context.unlocked
&& context.document_open
&& !context.switching_vault
&& context.focused_sensitive
}
UiAction::Lock => context.unlocked,
}
@@ -253,7 +295,7 @@ pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> O
match key.as_ref() {
keyboard::Key::Character(",") => Some(UiAction::Settings),
keyboard::Key::Character("n" | "N") => Some(UiAction::NewEntry),
keyboard::Key::Character("o" | "O") => Some(UiAction::OpenEntry),
keyboard::Key::Character("o" | "O") => Some(UiAction::OpenFolder),
keyboard::Key::Character("s" | "S") => Some(UiAction::Save),
keyboard::Key::Character("w" | "W") => Some(UiAction::CloseWindow),
keyboard::Key::Character("q" | "Q") => Some(UiAction::Quit),
@@ -285,6 +327,7 @@ mod tests {
editing: true,
dirty: true,
saving: false,
switching_vault: false,
focused_field: true,
focused_sensitive: true,
entry_path: true,
@@ -350,13 +393,37 @@ mod tests {
..ready
}
));
assert!(!enabled(
UiAction::OpenFolder,
ActionContext {
switching_vault: true,
..ready
}
));
let switching = ActionContext {
switching_vault: true,
..ready
};
for action in [
UiAction::Save,
UiAction::CloseWindow,
UiAction::Quit,
UiAction::CopyField,
UiAction::CopyEditedField,
UiAction::Refresh,
UiAction::ReloadEntry,
UiAction::EditEntry,
UiAction::ToggleReveal,
] {
assert!(!enabled(action, switching), "{action:?}");
}
}
#[test]
fn conventional_shortcuts_resolve_to_the_registered_actions() {
let primary = keyboard::Modifiers::COMMAND;
for (key, expected) in [
("o", UiAction::OpenEntry),
("o", UiAction::OpenFolder),
("s", UiAction::Save),
("f", UiAction::Find),
("n", UiAction::NewEntry),

View File

@@ -0,0 +1,69 @@
//! Platform folder pickers without helper processes.
use std::path::PathBuf;
#[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(target_os = "linux")]
pub async fn pick_folder(initial: Option<PathBuf>) -> Result<Option<PathBuf>, String> {
use ashpd::{
PortalError,
desktop::{file_chooser::SelectedFiles, request::ResponseError},
};
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")]
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());
}
}

View File

@@ -3,11 +3,13 @@
mod action;
mod editor;
mod folder_picker;
#[cfg(target_os = "macos")]
mod native_menu;
mod navigation;
use std::{
path::PathBuf,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
@@ -58,6 +60,11 @@ enum Message {
ToggleMenu(MenuGroup),
DismissUtility,
WindowResolved(UiAction, Option<window::Id>),
FolderPicked(Result<Option<PathBuf>, String>),
VaultSwitched {
generation: u64,
result: Box<Result<DesktopStorage, String>>,
},
#[cfg(target_os = "macos")]
PollNativeMenu,
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
@@ -161,6 +168,7 @@ struct App {
authentication_generation: u64,
operation_generation: u64,
tree_generation: u64,
vault_generation: u64,
panes: pane_grid::State<PaneKind>,
pane_focus: PaneFocus,
navigation: NavigationTree,
@@ -170,6 +178,7 @@ struct App {
editor: Option<EntryEditor>,
content_mode: ContentMode,
saving: bool,
switching_vault: bool,
confirmation: Option<PendingAction>,
after_save: Option<PendingAction>,
generate_confirmation: Option<EntryFieldId>,
@@ -216,6 +225,7 @@ enum TreeState {
#[derive(Clone, Debug, Eq, PartialEq)]
enum PendingAction {
OpenVault(PathBuf),
OpenEntry(String),
Reload(String),
CloseWindow(window::Id),
@@ -259,6 +269,7 @@ impl App {
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
vault_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical,
ratio: 0.28,
@@ -273,6 +284,7 @@ impl App {
editor: None,
content_mode: ContentMode::Viewer,
saving: false,
switching_vault: false,
confirmation: None,
after_save: None,
generate_confirmation: None,
@@ -321,6 +333,53 @@ impl App {
_ => Task::none(),
};
}
Message::FolderPicked(result) => match result {
Ok(Some(path)) => return self.request_action(PendingAction::OpenVault(path)),
Ok(None) => {
self.status = "Open Folder cancelled; current vault unchanged.".to_owned();
}
Err(error) => {
self.status =
format!("Folder picker failed: {error}. Current vault unchanged.");
}
},
Message::VaultSwitched { generation, result } => {
if generation != self.vault_generation {
return Task::none();
}
self.switching_vault = false;
match *result {
Ok(storage) => {
let vault = storage.vault().display().to_string();
self.operation_generation = self.operation_generation.wrapping_add(1);
self.authentication_generation =
self.authentication_generation.wrapping_add(1);
self.sensitive.clear();
if let Some(session) = &self.session {
let _ignored = session.manual_lock();
}
self.storage = Some(storage);
self.handle = None;
self.authentication = AuthenticationView::Locked;
self.editor = None;
self.content_mode = ContentMode::Viewer;
self.navigation = NavigationTree::default();
self.tree_state = TreeState::Loading;
self.entry_path.clear();
self.confirmation = None;
self.after_save = None;
self.after_authentication = None;
self.generate_confirmation = None;
self.conflict = false;
self.status = format!("Opened password store at {vault}.");
return self.begin_tree_refresh();
}
Err(error) => {
self.status =
format!("Open Folder failed: {error}. Current vault unchanged.");
}
}
}
#[cfg(target_os = "macos")]
Message::PollNativeMenu => {
if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) {
@@ -648,6 +707,7 @@ impl App {
editing: self.content_mode == ContentMode::Editor,
dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty),
saving: self.saving,
switching_vault: self.switching_vault,
focused_field: focused.is_some(),
focused_sensitive: focused
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive),
@@ -680,6 +740,14 @@ impl App {
UiAction::About => self.utility = Some(UtilityView::About),
UiAction::Settings => self.utility = Some(UtilityView::Settings),
UiAction::Help => self.utility = Some(UtilityView::Help),
UiAction::OpenFolder => {
let initial = self
.storage
.as_ref()
.map(|storage| storage.vault().to_owned());
self.status = "Choose a password-store folder…".to_owned();
return Task::perform(folder_picker::pick_folder(initial), Message::FolderPicked);
}
UiAction::OpenEntry => return self.update(Message::OpenEntry),
UiAction::Save => return self.begin_save(),
UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => {
@@ -746,6 +814,11 @@ impl App {
}
fn request_action(&mut self, action: PendingAction) -> Task<Message> {
if self.switching_vault && matches!(action, PendingAction::CloseWindow(_)) {
self.status =
"Wait for vault validation to finish before closing IronStorage.".to_owned();
return Task::none();
}
if self.saving {
self.after_save = Some(action);
self.status = "Waiting for the active save to finish…".to_owned();
@@ -764,6 +837,7 @@ impl App {
fn execute_action(&mut self, action: PendingAction) -> Task<Message> {
match action {
PendingAction::OpenVault(path) => self.begin_vault_switch(path),
PendingAction::OpenEntry(entry) | PendingAction::Reload(entry) => {
self.editor = None;
self.content_mode = ContentMode::Viewer;
@@ -776,6 +850,27 @@ impl App {
}
}
fn begin_vault_switch(&mut self, path: PathBuf) -> Task<Message> {
let Some(storage) = self.storage.clone() else {
self.status = "Load a valid shared configuration before opening a folder.".to_owned();
return Task::none();
};
self.vault_generation = self.vault_generation.wrapping_add(1);
let generation = self.vault_generation;
self.switching_vault = true;
self.status = format!("Validating password store at {}", path.display());
Task::perform(
async move {
Box::new(
storage
.switch_vault(&path)
.map_err(|error| error.to_string()),
)
},
move |result| Message::VaultSwitched { generation, result },
)
}
fn begin_open(&mut self, entry: String) -> Task<Message> {
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
self.after_authentication = Some(PendingAction::OpenEntry(entry));
@@ -997,6 +1092,14 @@ impl App {
column![
row![
text(authentication),
text(
self.storage
.as_ref()
.map_or("No configured vault".to_owned(), |storage| {
storage.vault().display().to_string()
})
)
.size(13),
text(&self.status).size(14),
text("Tab changes pane focus").size(12),
]
@@ -1074,7 +1177,23 @@ fn utility_view(app: &App, utility: UtilityView) -> Element<'_, Message> {
UtilityView::Settings => {
content = content.push(text("Settings").size(28));
if let Some(storage) = &app.storage {
let editor = storage.configured_editor().map_or_else(
|| "Environment or built-in fallback".to_owned(),
|editor| {
std::iter::once(editor.program())
.chain(editor.arguments().iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" ")
},
);
content = content
.push(text(format!(
"Configuration: {}",
storage.config_source().display()
)))
.push(text(format!("Vault: {}", storage.vault().display())))
.push(text(format!("Default key: {}", storage.default_key())))
.push(text(format!("Editor: {editor}")))
.push(text(format!(
"Authentication inactivity timeout: {} seconds",
storage.authentication_timeout().duration().as_secs()
@@ -1202,12 +1321,19 @@ fn content_view(app: &App) -> Element<'_, Message> {
.on_input(Message::EntryPathChanged)
.on_submit(Message::Action(UiAction::OpenEntry)),
button("Open").on_press(Message::Action(UiAction::OpenEntry)),
button("Open Folder…").on_press(Message::Action(UiAction::OpenFolder)),
button("Reload").on_press(Message::Action(UiAction::ReloadEntry)),
button("Lock").on_press(Message::Action(UiAction::Lock)),
]
.spacing(8);
let body: Element<'_, Message> = if !authentication_allows_content(&app.authentication) {
let body: Element<'_, Message> = if app.switching_vault {
container(text(
"Validating the selected password store and shared configuration…",
))
.center(Length::Fill)
.into()
} else if !authentication_allows_content(&app.authentication) {
container(text(
"Protected entry content is locked. Select an entry to authenticate and open it.",
))
@@ -1458,6 +1584,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
let description = match action {
PendingAction::OpenVault(path) => format!("Open {}", path.display()),
PendingAction::OpenEntry(entry) => format!("Open {entry}"),
PendingAction::Reload(entry) => format!("Reload {entry}"),
PendingAction::CloseWindow(_) => "Close IronStorage".to_owned(),
@@ -1790,6 +1917,7 @@ mod tests {
authentication_generation: 0,
operation_generation: 0,
tree_generation: 0,
vault_generation: 0,
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
axis: pane_grid::Axis::Vertical,
ratio: 0.28,
@@ -1804,6 +1932,7 @@ mod tests {
editor,
content_mode: ContentMode::Viewer,
saving: false,
switching_vault: false,
confirmation: None,
after_save: None,
generate_confirmation: None,
@@ -1968,13 +2097,15 @@ mod tests {
editor.add_after(None).expect("line");
assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm);
for action in [
PendingAction::OpenVault(PathBuf::from("/selected-vault")),
PendingAction::OpenEntry("other".to_owned()),
PendingAction::Reload("draft".to_owned()),
PendingAction::CloseWindow(window::Id::unique()),
] {
assert!(matches!(
action,
PendingAction::OpenEntry(_)
PendingAction::OpenVault(_)
| PendingAction::OpenEntry(_)
| PendingAction::Reload(_)
| PendingAction::CloseWindow(_)
));
@@ -1984,6 +2115,18 @@ mod tests {
let draft = editor.document().serialize().expose().to_vec();
let mut app = test_app(Some(editor));
let _task = app.update(Message::FolderPicked(Ok(None)));
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
let selected_vault = PathBuf::from("/selected-vault");
let _task = app.update(Message::FolderPicked(Ok(Some(selected_vault.clone()))));
assert_eq!(
app.confirmation,
Some(PendingAction::OpenVault(selected_vault))
);
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
let _task = app.update(Message::CancelDiscard);
assert!(app.confirmation.is_none());
let draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft path"));
let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path"));
app.navigation.replace_test_nodes(vec![
@@ -2044,6 +2187,49 @@ mod tests {
let _task = app.update(Message::WindowResolved(UiAction::CloseWindow, Some(id)));
assert_eq!(app.confirmation, Some(PendingAction::CloseWindow(id)));
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
app.confirmation = None;
app.switching_vault = true;
let _task = app.update(Message::RequestClose(id));
assert!(app.confirmation.is_none());
assert!(app.status.contains("Wait for vault validation"));
}
#[test]
fn vault_switch_result_replaces_state_only_after_storage_success() {
let (_temporary, storage) = fixture_storage();
let mut failed = test_app(None);
failed.storage = Some(storage.clone());
failed.switching_vault = true;
failed.vault_generation = 4;
let original = failed.storage.as_ref().expect("storage").vault().to_owned();
let _task = failed.update(Message::VaultSwitched {
generation: 4,
result: Box::new(Err("injected failure".to_owned())),
});
assert_eq!(failed.storage.as_ref().expect("storage").vault(), original);
assert!(!failed.switching_vault);
assert!(failed.status.contains("Current vault unchanged"));
let mut editor = empty_editor(&storage, "draft");
editor.add_after(None).expect("dirty line");
let mut succeeded = test_app(Some(editor));
succeeded.authentication = AuthenticationView::Unlocked(Duration::from_secs(60));
succeeded.switching_vault = true;
succeeded.vault_generation = 5;
succeeded.operation_generation = 8;
let _task = succeeded.update(Message::VaultSwitched {
generation: 5,
result: Box::new(Ok(storage)),
});
assert!(succeeded.editor.is_none());
assert!(matches!(
succeeded.authentication,
AuthenticationView::Locked
));
assert_eq!(succeeded.tree_state, TreeState::Loading);
assert!(!succeeded.switching_vault);
assert_eq!(succeeded.operation_generation, 9);
}
#[test]

View File

@@ -116,7 +116,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
let (modifiers, code) = match action {
UiAction::Settings => (command, Code::Comma),
UiAction::NewEntry => (command, Code::KeyN),
UiAction::OpenEntry => (command, Code::KeyO),
UiAction::OpenFolder => (command, Code::KeyO),
UiAction::Save => (command, Code::KeyS),
UiAction::CloseWindow => (command, Code::KeyW),
UiAction::Quit => (command, Code::KeyQ),
@@ -132,6 +132,7 @@ fn accelerator(action: UiAction) -> Option<Accelerator> {
| UiAction::CopyField
| UiAction::CopyEditedField
| UiAction::TogglePaneFocus
| UiAction::OpenEntry
| UiAction::ReloadEntry
| UiAction::EditEntry
| UiAction::ToggleReveal