Build Iced desktop application shell
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
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 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user