Build Iced desktop application shell
This commit is contained in:
@@ -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<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>>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Message {
|
||||
StartupLoaded(Box<Result<(Config, NativeAuthenticationSession, KeyInfo), String>>),
|
||||
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
|
||||
TreeLoaded {
|
||||
generation: u64,
|
||||
completion: TreeCompletion,
|
||||
@@ -137,7 +136,7 @@ impl SensitiveUiState {
|
||||
|
||||
struct App {
|
||||
authentication: AuthenticationView,
|
||||
config: Option<Config>,
|
||||
storage: Option<DesktopStorage>,
|
||||
session: Option<NativeAuthenticationSession>,
|
||||
key: Option<KeyInfo>,
|
||||
handle: Option<NativeAuthenticationHandle>,
|
||||
@@ -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<Message> {
|
||||
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<Message> {
|
||||
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<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));
|
||||
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<Message> {
|
||||
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<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> {
|
||||
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<EntryDocument, String> {
|
||||
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<TreeModel, String> {
|
||||
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<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(),
|
||||
})
|
||||
#[cfg(test)]
|
||||
fn save_document(
|
||||
storage: &DesktopStorage,
|
||||
editor: &EntryEditor,
|
||||
) -> Result<WriteOutcome, DesktopError> {
|
||||
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<EntryEditor>) -> 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);
|
||||
|
||||
178
crates/storage/src/desktop.rs
Normal file
178
crates/storage/src/desktop.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user