Build Iced desktop application shell

This commit is contained in:
2026-08-10 16:15:58 +02:00
parent 08f74bfa72
commit f363081fa2
3 changed files with 262 additions and 134 deletions

View File

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

View File

@@ -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<Self, DesktopError> {
Config::load(explicit)
.map(|config| Self { config })
.map_err(|error| DesktopError::new(DesktopErrorKind::Configuration, error))
}
pub fn system() -> Result<DesktopBootstrap, DesktopError> {
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<TreeModel, DesktopError> {
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<EntryDocument, DesktopError> {
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<WriteOutcome, DesktopError> {
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<WriteOutcome, DesktopError> {
handle
.ensure_active()
.map_err(|error| DesktopError::new(DesktopErrorKind::Authentication, error))?;
self.save_document(document)
}
fn repository(&self) -> Result<Repository, DesktopError> {
Repository::open(self.config.vault())
.map_err(|error| DesktopError::new(DesktopErrorKind::Repository, error))
}
fn keys(&self) -> Result<KeyStore, DesktopError> {
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)
}
}

View File

@@ -9,6 +9,7 @@ pub mod authentication;
pub mod command; pub mod command;
pub mod config; pub mod config;
pub mod crypto; pub mod crypto;
pub mod desktop;
pub mod document; pub mod document;
pub mod generate; pub mod generate;
pub mod git; pub mod git;