From f363081fa2847b4c6e9dcd15133af478e01796ea Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 10 Aug 2026 16:15:58 +0200 Subject: [PATCH] Build Iced desktop application shell --- apps/desktop/src/main.rs | 217 +++++++++++++--------------------- crates/storage/src/desktop.rs | 178 ++++++++++++++++++++++++++++ crates/storage/src/lib.rs | 1 + 3 files changed, 262 insertions(+), 134 deletions(-) create mode 100644 crates/storage/src/desktop.rs diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index e35d444..594ed45 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -24,27 +24,26 @@ use ironstorage::{ AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession, NativeAuthenticationHandle, NativeAuthenticationSession, }, - config::Config, - crypto::{KeyInfo, KeyStore}, + crypto::KeyInfo, + desktop::{DesktopError, DesktopErrorKind, DesktopStorage}, document::{DocumentError, EntryDocument, EntryFieldId, EntryFieldKind, EntrySensitivity}, generate::GeneratorConfig, - git::{AutomaticEntryCommitter, GitIdentity}, presentation::{ClipboardWait, NativeClipboardManager}, read::{TreeModel, TreeNodeId}, - repository::{Repository, SecretBytes}, - secret_store::{SecretProtectionPolicy, SecretStoreBackend}, - write::{WriteError, WriteOutcome}, + repository::SecretBytes, + secret_store::SecretStoreBackend, + write::WriteOutcome, }; use navigation::{NavigationIntent, NavigationKey, NavigationTree}; use zeroize::Zeroizing; type OpenCompletion = Arc>>>; -type SaveCompletion = Arc)>>>; +type SaveCompletion = Arc)>>>; type TreeCompletion = Arc>>>; #[derive(Clone)] enum Message { - StartupLoaded(Box>), + StartupLoaded(Box>), TreeLoaded { generation: u64, completion: TreeCompletion, @@ -137,7 +136,7 @@ impl SensitiveUiState { struct App { authentication: AuthenticationView, - config: Option, + storage: Option, session: Option, key: Option, handle: Option, @@ -175,6 +174,7 @@ enum PaneFocus { #[derive(Clone, Debug, Eq, PartialEq)] enum TreeState { Loading, + Empty, Ready, Error(String), } @@ -199,19 +199,6 @@ enum LeasePoll { Expired, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SaveFailureKind { - Conflict, - Unchanged, - Storage, -} - -#[derive(Debug)] -struct SaveFailure { - kind: SaveFailureKind, - message: String, -} - fn main() -> iced::Result { iced::application(App::new, App::update, App::view) .title(ironstorage::PRODUCT_NAME) @@ -230,7 +217,7 @@ impl App { ( Self { authentication: AuthenticationView::Loading, - config: None, + storage: None, session: None, key: None, handle: None, @@ -266,8 +253,8 @@ impl App { fn update(&mut self, message: Message) -> Task { match message { Message::StartupLoaded(result) => match *result { - Ok((config, session, key)) => { - self.config = Some(config); + Ok((storage, session, key)) => { + self.storage = Some(storage); self.session = Some(session); self.key = Some(key); self.authentication = AuthenticationView::Locked; @@ -294,15 +281,15 @@ impl App { match result { Ok(model) => { self.navigation.replace(&model); - self.tree_state = TreeState::Ready; - self.status = if self.navigation.is_empty() { + self.tree_state = tree_state_from_result(Ok(self.navigation.is_empty())); + self.status = if self.tree_state == TreeState::Empty { "The password store is empty.".to_owned() } else { "Password-store tree refreshed.".to_owned() }; } Err(error) => { - self.tree_state = TreeState::Error(error.clone()); + self.tree_state = tree_state_from_result(Err(error.clone())); self.status = format!("Tree refresh failed: {error}"); } } @@ -423,7 +410,7 @@ impl App { } Message::CancelGenerate => self.generate_confirmation = None, Message::Copy(id) => { - let (Some(config), Some(editor)) = (&self.config, &self.editor) else { + let (Some(storage), Some(editor)) = (&self.storage, &self.editor) else { return Task::none(); }; let value = match editor.copy_value(id) { @@ -436,7 +423,7 @@ impl App { let (generation, cancel) = self.sensitive.begin_copy(); self.status = "Copied; automatic clipboard cleanup is active.".to_owned(); return Task::perform( - copy_to_clipboard(value, config.clipboard_timeout(), cancel), + copy_to_clipboard(value, storage.clipboard_timeout(), cancel), move |result| Message::CopyFinished { generation, result }, ); } @@ -467,8 +454,8 @@ impl App { return self.begin_open(outcome.path().to_string()); } Err(error) => { - self.conflict = error.kind == SaveFailureKind::Conflict; - self.status = format!("Save failed: {}. Draft retained.", error.message); + self.conflict = error.kind() == DesktopErrorKind::Conflict; + self.status = format!("Save failed: {error}. Draft retained."); self.editor = Some(editor); self.confirmation = self.after_save.take(); } @@ -535,14 +522,18 @@ impl App { } fn begin_tree_refresh(&mut self) -> Task { - let Some(config) = self.config.clone() else { + let Some(storage) = self.storage.clone() else { return Task::none(); }; self.tree_generation = self.tree_generation.wrapping_add(1); let generation = self.tree_generation; self.tree_state = TreeState::Loading; Task::perform( - async move { Arc::new(Mutex::new(Some(load_tree(&config)))) }, + async move { + Arc::new(Mutex::new(Some( + storage.tree().map_err(|error| error.to_string()), + ))) + }, move |completion| Message::TreeLoaded { generation, completion, @@ -596,7 +587,7 @@ impl App { } fn begin_open(&mut self, entry: String) -> Task { - let (Some(config), Some(handle)) = (self.config.clone(), self.handle.clone()) else { + let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { self.after_authentication = Some(PendingAction::OpenEntry(entry)); return self.begin_authentication(); }; @@ -605,7 +596,7 @@ impl App { self.status = format!("Opening {entry}…"); Task::perform( async move { - let result = load_document(&config, &entry, handle); + let result = load_document(&storage, &entry, handle); let completion = Arc::new(Mutex::new(Some(result))); (entry, completion) }, @@ -640,7 +631,7 @@ impl App { fn begin_save(&mut self) -> Task { self.touch_user_activity(); - let (Some(config), Some(handle)) = (self.config.clone(), self.handle.clone()) else { + let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { return Task::none(); }; let Some(editor) = self.editor.take() else { @@ -657,10 +648,7 @@ impl App { self.status = format!("Saving {}…", editor.entry()); Task::perform( async move { - let result = handle - .ensure_active() - .map_err(|error| SaveFailure::storage(error.to_string())) - .and_then(|()| save_document(&config, &editor)); + let result = storage.save_active_document(&handle, editor.document()); Arc::new(Mutex::new(Some((editor, result)))) }, move |completion| Message::SaveFinished { @@ -807,7 +795,7 @@ fn sidebar_view<'a>( TreeState::Error(error) => { rows = rows.push(text(format!("Tree unavailable: {error}")).size(13)); } - TreeState::Ready if navigation.is_empty() => { + TreeState::Empty => { rows = rows.push(text("This password store is empty.").size(13)); } TreeState::Ready => {} @@ -897,15 +885,6 @@ fn content_view(app: &App) -> Element<'_, Message> { .into() } -impl SaveFailure { - fn storage(message: String) -> Self { - Self { - kind: SaveFailureKind::Storage, - message, - } - } -} - fn dirty_decision(editor: Option<&EntryEditor>) -> DirtyDecision { if editor.is_some_and(EntryEditor::is_dirty) { DirtyDecision::Confirm @@ -914,6 +893,14 @@ fn dirty_decision(editor: Option<&EntryEditor>) -> DirtyDecision { } } +fn tree_state_from_result(result: Result) -> TreeState { + match result { + Ok(true) => TreeState::Empty, + Ok(false) => TreeState::Ready, + Err(error) => TreeState::Error(error), + } +} + fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> { let mut fields = column![ row![ @@ -1034,65 +1021,29 @@ fn generate_confirmation_view(id: EntryFieldId) -> Element<'static, Message> { .into() } -async fn load_authentication() -> Result<(Config, NativeAuthenticationSession, KeyInfo), String> { - let config = Config::load(None).map_err(|error| error.to_string())?; - let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?; - let handle = keys - .resolve(config.default_key().as_str()) - .map_err(|error| error.to_string())?; - let key = keys - .infos() - .find(|key| key.fingerprint() == handle.fingerprint()) - .ok_or_else(|| "the configured GPG key is unavailable".to_owned())?; - let session = NativeAuthenticationSession::system( - SecretProtectionPolicy::default(), - config.authentication_timeout(), - ) - .map_err(|error| error.to_string())?; - Ok((config, session, key)) +async fn load_authentication() +-> Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String> { + DesktopStorage::system() + .map(|bootstrap| bootstrap.into_parts()) + .map_err(|error| error.to_string()) } fn load_document( - config: &Config, + storage: &DesktopStorage, entry: &str, mut handle: NativeAuthenticationHandle, ) -> Result { - let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?; - let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?; - ironstorage::document::EntryDocumentService::new(&repository, &keys) - .open(entry, &mut handle) + storage + .open_document(entry, &mut handle) .map_err(|error| error.to_string()) } -fn load_tree(config: &Config) -> Result { - let repository = Repository::open(config.vault()).map_err(|error| error.to_string())?; - let keys = KeyStore::load(config.key_material()).map_err(|error| error.to_string())?; - ironstorage::read::VaultReader::new(&repository, &keys) - .list(&ironstorage::repository::DirectoryPath::root()) - .map_err(|error| error.to_string()) -} - -fn save_document(config: &Config, editor: &EntryEditor) -> Result { - let repository = Repository::open(config.vault()) - .map_err(|error| SaveFailure::storage(error.to_string()))?; - let keys = KeyStore::load(config.key_material()) - .map_err(|error| SaveFailure::storage(error.to_string()))?; - let entry = editor.entry(); - let mut committer = - AutomaticEntryCommitter::for_entry(&repository, &entry, GitIdentity::ironstorage()) - .map_err(|error| SaveFailure::storage(error.to_string()))?; - ironstorage::document::EntryDocumentService::new(&repository, &keys) - .save_recoverable(editor.document(), None, &mut committer) - .map_err(|error| SaveFailure { - kind: match &error { - DocumentError::Write(WriteError::ConcurrentModification { .. }) => { - SaveFailureKind::Conflict - } - DocumentError::Write(WriteError::Unchanged) => SaveFailureKind::Unchanged, - _ => SaveFailureKind::Storage, - }, - message: error.to_string(), - }) +#[cfg(test)] +fn save_document( + storage: &DesktopStorage, + editor: &EntryEditor, +) -> Result { + storage.save_document(editor.document()) } async fn copy_to_clipboard( @@ -1190,12 +1141,11 @@ mod tests { use iced::{Point, window}; use ironstorage::{ authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}, - crypto::{SecretProvider, SecretProviderError}, - document::EntryDocumentService, + crypto::{KeyStore, SecretProvider, SecretProviderError}, repository::EntryPath, secret_store::{ - SecretCachePolicy, SecretLocator, SecretProtection, SecretReference, SecretStore, - SecretStoreError, + SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, + SecretReference, SecretStore, SecretStoreError, }, }; @@ -1321,7 +1271,7 @@ mod tests { (session, key, clock) } - fn fixture_config() -> (tempfile::TempDir, Config) { + fn fixture_storage() -> (tempfile::TempDir, DesktopStorage) { let temporary = tempfile::tempdir().expect("temporary vault"); let vault = temporary.path().join("vault"); let native = temporary.path().join("native"); @@ -1343,15 +1293,13 @@ mod tests { ), ) .expect("configuration"); - let config = Config::load(Some(&config_path)).expect("load configuration"); - (temporary, config) + let storage = DesktopStorage::load(Some(&config_path)).expect("load configuration"); + (temporary, storage) } - fn empty_editor(config: &Config, entry: &str) -> EntryEditor { - let repository = Repository::open(config.vault()).expect("repository"); - let keys = KeyStore::load(config.key_material()).expect("keys"); - let document = EntryDocumentService::new(&repository, &keys) - .open(entry, &mut FixtureSecrets) + fn empty_editor(storage: &DesktopStorage, entry: &str) -> EntryEditor { + let document = storage + .open_document(entry, &mut FixtureSecrets) .expect("document"); EntryEditor::new(document) } @@ -1359,7 +1307,7 @@ mod tests { fn test_app(editor: Option) -> App { App { authentication: AuthenticationView::Locked, - config: None, + storage: None, session: None, key: None, handle: None, @@ -1390,8 +1338,8 @@ mod tests { #[test] fn structured_fields_save_round_trip_and_stale_drafts_remain_recoverable() { - let (_temporary, config) = fixture_config(); - let mut editor = empty_editor(&config, "documents/editable"); + let (_temporary, storage) = fixture_storage(); + let mut editor = empty_editor(&storage, "documents/editable"); editor.add_after(None).expect("password line"); let password = editor.fields()[0].id(); editor.update_raw(password, b"password").expect("password"); @@ -1426,42 +1374,35 @@ mod tests { let expected = b"password\nusername: alice\nfirst note line\ncustom-field: opaque\nsecond note line"; assert_eq!(editor.document().serialize().expose(), expected); - save_document(&config, &editor).expect("initial save"); + save_document(&storage, &editor).expect("initial save"); - let reopened = empty_editor(&config, "documents/editable"); + let reopened = empty_editor(&storage, "documents/editable"); assert_eq!(reopened.document().serialize().expose(), expected); - let mut winner = empty_editor(&config, "documents/editable"); - let mut stale = empty_editor(&config, "documents/editable"); + let mut winner = empty_editor(&storage, "documents/editable"); + let mut stale = empty_editor(&storage, "documents/editable"); let winner_password = winner.fields()[0].id(); winner .update_raw(winner_password, b"winner") .expect("winner edit"); - save_document(&config, &winner).expect("winner save"); + save_document(&storage, &winner).expect("winner save"); let stale_password = stale.fields()[0].id(); stale .update_raw(stale_password, b"complete stale draft") .expect("stale edit"); - let failure = save_document(&config, &stale).expect_err("stale save"); - assert_eq!(failure.kind, SaveFailureKind::Conflict); + let failure = save_document(&storage, &stale).expect_err("stale save"); + assert_eq!(failure.kind(), DesktopErrorKind::Conflict); assert!(stale.is_dirty()); assert_eq!( stale.document().serialize().expose(), b"complete stale draft\nusername: alice\nfirst note line\ncustom-field: opaque\nsecond note line" ); - - let repository = Repository::open(config.vault()).expect("repository"); - assert!( - repository - .read_entry(&EntryPath::parse("documents/editable").expect("path")) - .is_ok() - ); } #[test] fn every_destructive_path_uses_the_same_save_discard_cancel_guard() { - let (_temporary, config) = fixture_config(); - let mut editor = empty_editor(&config, "draft"); + let (_temporary, storage) = fixture_storage(); + let mut editor = empty_editor(&storage, "draft"); editor.add_after(None).expect("line"); assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm); for action in [ @@ -1525,8 +1466,8 @@ mod tests { #[test] fn lock_and_expiry_drop_the_complete_editor_and_clipboard_state() { - let (_temporary, config) = fixture_config(); - let mut editor = empty_editor(&config, "dirty"); + let (_temporary, storage) = fixture_storage(); + let mut editor = empty_editor(&storage, "dirty"); editor.add_after(None).expect("dirty line"); let mut app = test_app(Some(editor)); app.sensitive.clipboard_cancel = Some(Arc::new(AtomicBool::new(false))); @@ -1596,6 +1537,14 @@ mod tests { #[test] fn pane_focus_and_storage_errors_are_handled_without_repository_access() { let mut app = test_app(None); + assert_eq!(app.tree_state, TreeState::Loading); + assert_eq!(tree_state_from_result(Ok(true)), TreeState::Empty); + assert_eq!(tree_state_from_result(Ok(false)), TreeState::Ready); + assert_eq!( + tree_state_from_result(Err("fake error".to_owned())), + TreeState::Error("fake error".to_owned()) + ); + assert!(matches!(app.authentication, AuthenticationView::Locked)); assert_eq!(app.pane_focus, PaneFocus::Sidebar); let _task = app.update(Message::TogglePaneFocus); assert_eq!(app.pane_focus, PaneFocus::Content); diff --git a/crates/storage/src/desktop.rs b/crates/storage/src/desktop.rs new file mode 100644 index 0000000..ff02df0 --- /dev/null +++ b/crates/storage/src/desktop.rs @@ -0,0 +1,178 @@ +//! Storage-owned service boundary for the Iced desktop presentation adapter. + +use std::{error::Error, fmt, path::Path}; + +use crate::{ + authentication::{NativeAuthenticationHandle, NativeAuthenticationSession}, + config::Config, + crypto::{KeyInfo, KeyStore, SecretProvider}, + document::{DocumentError, EntryDocument, EntryDocumentService}, + git::{AutomaticEntryCommitter, GitIdentity}, + presentation::ClipboardTimeout, + read::{TreeModel, VaultReader}, + repository::{DirectoryPath, Repository}, + secret_store::SecretProtectionPolicy, + write::{WriteError, WriteOutcome}, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DesktopErrorKind { + Configuration, + Authentication, + KeyMaterial, + Repository, + Read, + Document, + Git, + Conflict, + Unchanged, + MissingDefaultKey, +} + +#[derive(Debug)] +pub struct DesktopError { + kind: DesktopErrorKind, + message: String, +} + +impl DesktopError { + pub fn kind(&self) -> DesktopErrorKind { + self.kind + } + + fn new(kind: DesktopErrorKind, error: impl fmt::Display) -> Self { + Self { + kind, + message: error.to_string(), + } + } + + fn document(error: DocumentError) -> Self { + let kind = match &error { + DocumentError::Write(WriteError::ConcurrentModification { .. }) => { + DesktopErrorKind::Conflict + } + DocumentError::Write(WriteError::Unchanged) => DesktopErrorKind::Unchanged, + _ => DesktopErrorKind::Document, + }; + Self::new(kind, error) + } +} + +impl fmt::Display for DesktopError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for DesktopError {} + +#[derive(Clone, Debug)] +pub struct DesktopStorage { + config: Config, +} + +impl DesktopStorage { + pub fn load(explicit: Option<&Path>) -> Result { + Config::load(explicit) + .map(|config| Self { config }) + .map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error)) + } + + pub fn system() -> Result { + let storage = Self::load(None)?; + let keys = KeyStore::load(storage.config.key_material()) + .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?; + let handle = keys + .resolve(storage.config.default_key().as_str()) + .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error))?; + let key = keys + .infos() + .find(|key| key.fingerprint() == handle.fingerprint()) + .ok_or_else(|| { + DesktopError::new( + DesktopErrorKind::MissingDefaultKey, + "the configured GPG key is unavailable", + ) + })?; + let authentication = NativeAuthenticationSession::system( + SecretProtectionPolicy::default(), + storage.config.authentication_timeout(), + ) + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + Ok(DesktopBootstrap { + storage, + authentication, + key, + }) + } + + pub fn clipboard_timeout(&self) -> ClipboardTimeout { + self.config.clipboard_timeout() + } + + pub fn tree(&self) -> Result { + let repository = self.repository()?; + let keys = self.keys()?; + VaultReader::new(&repository, &keys) + .list(&DirectoryPath::root()) + .map_err(|error| DesktopError::new(DesktopErrorKind::Read, error)) + } + + pub fn open_document( + &self, + entry: &str, + secrets: &mut impl SecretProvider, + ) -> Result { + let repository = self.repository()?; + let keys = self.keys()?; + EntryDocumentService::new(&repository, &keys) + .open(entry, secrets) + .map_err(DesktopError::document) + } + + pub fn save_document(&self, document: &EntryDocument) -> Result { + let repository = self.repository()?; + let keys = self.keys()?; + let entry = document.path().to_string(); + let mut committer = + AutomaticEntryCommitter::for_entry(&repository, &entry, GitIdentity::ironstorage()) + .map_err(|error| DesktopError::new(DesktopErrorKind::Git, error))?; + EntryDocumentService::new(&repository, &keys) + .save_recoverable(document, None, &mut committer) + .map_err(DesktopError::document) + } + + pub fn save_active_document( + &self, + handle: &NativeAuthenticationHandle, + document: &EntryDocument, + ) -> Result { + handle + .ensure_active() + .map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?; + self.save_document(document) + } + + fn repository(&self) -> Result { + Repository::open(self.config.vault()) + .map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error)) + } + + fn keys(&self) -> Result { + KeyStore::load(self.config.key_material()) + .map_err(|error| DesktopError::new(DesktopErrorKind::KeyMaterial, error)) + } +} + +pub struct DesktopBootstrap { + storage: DesktopStorage, + authentication: NativeAuthenticationSession, + key: KeyInfo, +} + +impl DesktopBootstrap { + pub fn into_parts(self) -> (DesktopStorage, NativeAuthenticationSession, KeyInfo) { + (self.storage, self.authentication, self.key) + } +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index c88b0cc..3c4f88b 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -9,6 +9,7 @@ pub mod authentication; pub mod command; pub mod config; pub mod crypto; +pub mod desktop; pub mod document; pub mod generate; pub mod git;