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,
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);

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 config;
pub mod crypto;
pub mod desktop;
pub mod document;
pub mod generate;
pub mod git;