#![forbid(unsafe_code)] #![deny(clippy::disallowed_types)] mod action; mod editor; #[cfg(target_os = "macos")] mod native_menu; mod navigation; use std::{ sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }, thread, time::{Duration, Instant}, }; use editor::{EntryEditor, FieldNavigation}; use iced::{ Element, Event, Length, Size, Subscription, Task, event, keyboard, mouse, time, touch, widget::{button, column, container, pane_grid, row, scrollable, text, text_input}, window, }; use ironstorage::{ authentication::{ AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession, NativeAuthenticationHandle, NativeAuthenticationSession, }, crypto::KeyInfo, desktop::{DesktopError, DesktopErrorKind, DesktopStorage}, document::{ DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId, EntryFieldKind, EntrySensitivity, }, generate::GeneratorConfig, presentation::{ClipboardWait, NativeClipboardManager}, read::{TreeModel, TreeNodeId}, repository::SecretBytes, secret_store::SecretStoreBackend, write::WriteOutcome, }; use navigation::{NavigationIntent, NavigationKey, NavigationTree}; use zeroize::Zeroizing; use action::{ActionContext, MenuGroup, UiAction}; #[cfg(target_os = "macos")] use native_menu::NativeMenu; type OpenCompletion = Arc>>>; type SaveCompletion = Arc)>>>; type TreeCompletion = Arc>>>; #[derive(Clone)] enum Message { Action(UiAction), FieldAction(EntryFieldId, UiAction), ToggleMenu(MenuGroup), DismissUtility, WindowResolved(UiAction, Option), #[cfg(target_os = "macos")] PollNativeMenu, StartupLoaded(Box>), TreeLoaded { generation: u64, completion: TreeCompletion, }, SidebarActivate(TreeNodeId), SidebarNavigate(NavigationKey), TogglePaneFocus, PaneResized(pane_grid::ResizeEvent), EntryPathChanged(String), OpenEntry, OpenFinished { generation: u64, entry: String, completion: OpenCompletion, }, AuthenticationFinished { generation: u64, result: Result, }, FieldChanged(EntryFieldId, Zeroizing), AddAfter(Option), Remove(EntryFieldId), MoveUp(EntryFieldId), MoveDown(EntryFieldId), BeginEdit, SelectField(EntryFieldId), ToggleReveal(EntryFieldId), RequestGenerate(EntryFieldId), ConfirmGenerate, CancelGenerate, Copy(EntryFieldId), CopyFinished { generation: u64, result: Result, }, SaveFinished { generation: u64, completion: SaveCompletion, }, RequestReload, RequestClose(window::Id), ConfirmSave, ConfirmDiscard, CancelDiscard, ReloadConflict, KeepConflictDraft, UserActivity, Tick, Lock, } #[derive(Debug)] enum AuthenticationView { Loading, Locked, Authenticating, Unlocked(Duration), Unavailable(String), } #[derive(Default)] struct SensitiveUiState { clipboard_cancel: Option>, clipboard_generation: u64, } impl SensitiveUiState { fn clear(&mut self) { self.clipboard_generation = self.clipboard_generation.wrapping_add(1); if let Some(cancel) = self.clipboard_cancel.take() { cancel.store(true, Ordering::Release); } } fn begin_copy(&mut self) -> (u64, Arc) { self.clear(); let cancel = Arc::new(AtomicBool::new(false)); self.clipboard_cancel = Some(Arc::clone(&cancel)); (self.clipboard_generation, cancel) } fn finish_copy(&mut self, generation: u64) -> bool { if generation != self.clipboard_generation { return false; } self.clipboard_cancel = None; true } } struct App { authentication: AuthenticationView, storage: Option, session: Option, key: Option, handle: Option, sensitive: SensitiveUiState, authentication_generation: u64, operation_generation: u64, tree_generation: u64, panes: pane_grid::State, pane_focus: PaneFocus, navigation: NavigationTree, tree_state: TreeState, after_authentication: Option, entry_path: String, editor: Option, content_mode: ContentMode, saving: bool, confirmation: Option, after_save: Option, generate_confirmation: Option, conflict: bool, status: String, open_menu: Option, utility: Option, #[cfg(target_os = "macos")] native_menu: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PaneKind { Sidebar, Content, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PaneFocus { Sidebar, Content, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ContentMode { Viewer, Editor, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum UtilityView { About, Settings, Help, } #[derive(Clone, Debug, Eq, PartialEq)] enum TreeState { Loading, Empty, Ready, Error(String), } #[derive(Clone, Debug, Eq, PartialEq)] enum PendingAction { OpenEntry(String), Reload(String), CloseWindow(window::Id), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum DirtyDecision { Confirm, Execute, } #[derive(Debug, Eq, PartialEq)] enum LeasePoll { Idle, Active(Duration), Expired, } fn main() -> iced::Result { iced::application(App::new, App::update, App::view) .title(ironstorage::PRODUCT_NAME) .subscription(App::subscription) .exit_on_close_request(false) .window(window::Settings { size: Size::new(1_080.0, 720.0), min_size: Some(Size::new(720.0, 480.0)), ..window::Settings::default() }) .run() } impl App { fn new() -> (Self, Task) { let mut app = Self { authentication: AuthenticationView::Loading, storage: None, session: None, key: None, handle: None, sensitive: SensitiveUiState::default(), authentication_generation: 0, operation_generation: 0, tree_generation: 0, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, ratio: 0.28, a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)), b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)), }), pane_focus: PaneFocus::Sidebar, navigation: NavigationTree::default(), tree_state: TreeState::Loading, after_authentication: None, entry_path: String::new(), editor: None, content_mode: ContentMode::Viewer, saving: false, confirmation: None, after_save: None, generate_confirmation: None, conflict: false, status: "Loading shared configuration…".to_owned(), open_menu: None, utility: None, #[cfg(target_os = "macos")] native_menu: None, }; #[cfg(target_os = "macos")] match NativeMenu::install(app.action_context()) { Ok(menu) => app.native_menu = Some(menu), Err(error) => app.status = format!("Native menu unavailable: {error}"), } ( app, Task::perform(load_authentication(), |result| { Message::StartupLoaded(Box::new(result)) }), ) } fn update(&mut self, message: Message) -> Task { match message { Message::Action(action) => return self.invoke_action(action), Message::FieldAction(id, action) => { if let Some(editor) = self.editor.as_mut() { editor.select(id); } return self.invoke_action(action); } Message::ToggleMenu(group) => { self.open_menu = (self.open_menu != Some(group)).then_some(group); } Message::DismissUtility => self.utility = None, Message::WindowResolved(action, id) => { let Some(id) = id else { return Task::none(); }; return match action { UiAction::CloseWindow | UiAction::Quit => { self.request_action(PendingAction::CloseWindow(id)) } UiAction::Minimize => window::minimize(id, true), _ => Task::none(), }; } #[cfg(target_os = "macos")] Message::PollNativeMenu => { if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) { return self.invoke_action(action); } } Message::StartupLoaded(result) => match *result { Ok((storage, session, key)) => { self.storage = Some(storage); self.session = Some(session); self.key = Some(key); self.authentication = AuthenticationView::Locked; self.status = "Entry names remain available while protected content is locked." .to_owned(); return self.begin_tree_refresh(); } Err(error) => { self.tree_state = TreeState::Error(error.clone()); self.authentication = AuthenticationView::Unavailable(error.clone()); self.status = error; } }, Message::TreeLoaded { generation, completion, } => { let Some(result) = take_completion(&completion) else { return Task::none(); }; if generation != self.tree_generation { return Task::none(); } match result { Ok(model) => { self.navigation.replace(&model); 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 = tree_state_from_result(Err(error.clone())); self.status = format!("Tree refresh failed: {error}"); } } } Message::SidebarActivate(id) => { self.touch_user_activity(); self.pane_focus = PaneFocus::Sidebar; let intent = self.navigation.activate(id); return self.handle_navigation(intent); } Message::SidebarNavigate(key) => { self.touch_user_activity(); match self.pane_focus { PaneFocus::Sidebar => { let intent = self.navigation.navigate(key); return self.handle_navigation(intent); } PaneFocus::Content if self.content_mode == ContentMode::Viewer => { return self.navigate_viewer(key); } PaneFocus::Content => {} } } Message::TogglePaneFocus => { self.touch_user_activity(); self.pane_focus = match self.pane_focus { PaneFocus::Sidebar => PaneFocus::Content, PaneFocus::Content => PaneFocus::Sidebar, }; } Message::PaneResized(event) => { self.panes .resize(event.split, event.ratio.clamp(0.18, 0.55)); } Message::EntryPathChanged(path) => { self.pane_focus = PaneFocus::Content; self.entry_path = path; } Message::OpenEntry => { self.pane_focus = PaneFocus::Content; let entry = self.entry_path.trim().to_owned(); if entry.is_empty() { self.status = "Enter an entry path first.".to_owned(); } else { return self.request_action(PendingAction::OpenEntry(entry)); } } Message::OpenFinished { generation, entry, completion, } => { let Some(result) = take_completion(&completion) else { return Task::none(); }; if generation != self.operation_generation { return Task::none(); } match result { Ok(document) => { self.entry_path = entry.clone(); let _selected = self.navigation.select_entry_path(&entry); self.pane_focus = PaneFocus::Content; self.editor = Some(EntryEditor::new(document)); self.content_mode = ContentMode::Viewer; self.conflict = false; self.status = format!("Viewing {entry}"); } Err(error) => self.status = format!("Open failed: {error}"), } } Message::AuthenticationFinished { generation, result } => { if generation != self.authentication_generation { if result.is_ok() && let Some(session) = &self.session { let _ignored = session.manual_lock(); } return Task::none(); } match result { Ok(handle) => match handle.remaining_time() { Ok(remaining) => { self.handle = Some(handle); self.authentication = AuthenticationView::Unlocked(remaining); self.status = "Protected content is unlocked.".to_owned(); if let Some(action) = self.after_authentication.take() { return self.execute_action(action); } } Err(error) => self.authentication_lost(error.to_string()), }, Err(error) => { self.authentication_lost(error.clone()); self.status = format!("Authentication failed: {error}"); } } } Message::FieldChanged(id, value) => { self.pane_focus = PaneFocus::Content; self.edit(|editor| editor.update_raw(id, value.as_bytes())); } Message::AddAfter(id) => self.edit(|editor| editor.add_after(id)), Message::Remove(id) => self.edit(|editor| editor.remove(id)), Message::MoveUp(id) => self.edit(|editor| editor.move_up(id)), Message::MoveDown(id) => self.edit(|editor| editor.move_down(id)), Message::BeginEdit => { if authentication_allows_content(&self.authentication) && let Some(editor) = &self.editor { self.content_mode = ContentMode::Editor; self.status = format!("Editing {}", editor.entry()); } } Message::SelectField(id) => { if let Some(editor) = self.editor.as_mut() { editor.select(id); } } Message::ToggleReveal(id) => { if authentication_allows_content(&self.authentication) && let Some(editor) = self.editor.as_mut() { match editor.toggle_reveal(id) { Ok(()) => { editor.select(id); self.status = if editor.is_revealed(id) { "Sensitive value revealed by explicit action.".to_owned() } else { "Sensitive value hidden.".to_owned() }; } Err(error) => self.status = error.to_string(), } } } Message::RequestGenerate(id) => { let has_value = self .editor .as_ref() .and_then(|editor| editor.document().field(id)) .is_some_and(|field| !field.value().is_empty()); if has_value { self.generate_confirmation = Some(id); } else { self.generate(id); } } Message::ConfirmGenerate => { if let Some(id) = self.generate_confirmation.take() { self.generate(id); } } Message::CancelGenerate => self.generate_confirmation = None, Message::Copy(id) => { if !authentication_allows_content(&self.authentication) { return Task::none(); } let (Some(storage), Some(editor)) = (&self.storage, &self.editor) else { return Task::none(); }; let value = match editor.copy_value(id) { Ok(value) => value, Err(error) => { self.status = error.to_string(); return Task::none(); } }; let (generation, cancel) = self.sensitive.begin_copy(); self.status = "Copied; automatic clipboard cleanup is active.".to_owned(); return Task::perform( copy_to_clipboard(value, storage.clipboard_timeout(), cancel), move |result| Message::CopyFinished { generation, result }, ); } Message::CopyFinished { generation, result } => { if self.sensitive.finish_copy(generation) { self.status = result.unwrap_or_else(|error| format!("Clipboard: {error}")); } } Message::SaveFinished { generation, completion, } => { let Some((editor, result)) = take_completion(&completion) else { return Task::none(); }; if generation != self.operation_generation { return Task::none(); } self.saving = false; match result { Ok(outcome) => { self.editor = None; self.conflict = false; if let Some(action) = self.after_save.take() { return self.execute_action(action); } return self.begin_open(outcome.path().to_string()); } Err(error) => { self.conflict = error.kind() == DesktopErrorKind::Conflict; self.status = format!("Save failed: {error}. Draft retained."); self.editor = Some(editor); self.confirmation = self.after_save.take(); } } } Message::RequestReload => { if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) { return self.request_action(PendingAction::Reload(entry)); } } Message::RequestClose(id) => { return self.request_action(PendingAction::CloseWindow(id)); } Message::ConfirmSave => { self.after_save = self.confirmation.take(); return self.begin_save(); } Message::ConfirmDiscard => { let action = self.confirmation.take(); self.editor = None; self.content_mode = ContentMode::Viewer; self.conflict = false; if let Some(action) = action { return self.execute_action(action); } } Message::CancelDiscard => self.confirmation = None, Message::ReloadConflict => { if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) { self.editor = None; self.content_mode = ContentMode::Viewer; self.conflict = false; return self.execute_action(PendingAction::Reload(entry)); } } Message::KeepConflictDraft => self.conflict = false, Message::UserActivity => self.touch_user_activity(), Message::Tick => { if let Some(session) = &self.session { match poll_lease(session, &mut self.handle, &mut self.sensitive) { Ok(LeasePoll::Active(remaining)) => { self.authentication = AuthenticationView::Unlocked(remaining); } Ok(LeasePoll::Expired) => { self.authentication_lost("the authentication lease expired".to_owned()); } Ok(LeasePoll::Idle) => {} Err(error) => self.authentication_lost(error.to_string()), } } } Message::Lock => { self.authentication_generation = self.authentication_generation.wrapping_add(1); let result = self .session .as_ref() .map_or(Ok(()), NativeAuthenticationSession::manual_lock); self.authentication_lost("manually locked".to_owned()); if let Err(error) = result { self.authentication = AuthenticationView::Unavailable(error.to_string()); self.status = format!("Lock failed: {error}"); } } } Task::none() } fn action_context(&self) -> ActionContext { let focused = self .editor .as_ref() .and_then(|editor| editor.focused().and_then(|id| editor.document().field(id))); ActionContext { storage_ready: self.storage.is_some(), tree_loading: self.tree_state == TreeState::Loading, unlocked: authentication_allows_content(&self.authentication), document_open: self.editor.is_some(), editing: self.content_mode == ContentMode::Editor, dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty), saving: self.saving, focused_field: focused.is_some(), focused_sensitive: focused .is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive), entry_path: !self.entry_path.trim().is_empty(), } } fn invoke_action(&mut self, action: UiAction) -> Task { self.open_menu = None; if !action::enabled(action, self.action_context()) { if self.content_mode == ContentMode::Editor && matches!( action, UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::CopyField | UiAction::Paste ) { return Task::none(); } self.status = format!( "{} is unavailable in the current state.", action::spec_for(action).label ); return Task::none(); } match action { UiAction::About => self.utility = Some(UtilityView::About), UiAction::Settings => self.utility = Some(UtilityView::Settings), UiAction::Help => self.utility = Some(UtilityView::Help), UiAction::OpenEntry => return self.update(Message::OpenEntry), UiAction::Save => return self.begin_save(), UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => { return window::oldest().map(move |id| Message::WindowResolved(action, id)); } UiAction::CopyField | UiAction::CopyEditedField => { if let Some(id) = self.editor.as_ref().and_then(EntryEditor::focused) { return self.update(Message::Copy(id)); } } UiAction::TogglePaneFocus => return self.update(Message::TogglePaneFocus), UiAction::Refresh => return self.begin_tree_refresh(), UiAction::ReloadEntry => return self.update(Message::RequestReload), UiAction::EditEntry => return self.update(Message::BeginEdit), UiAction::ToggleReveal => { if let Some(id) = self.editor.as_ref().and_then(EntryEditor::focused) { return self.update(Message::ToggleReveal(id)); } } UiAction::Lock => return self.update(Message::Lock), UiAction::NewEntry | UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => {} } Task::none() } fn begin_tree_refresh(&mut self) -> Task { 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( storage.tree().map_err(|error| error.to_string()), ))) }, move |completion| Message::TreeLoaded { generation, completion, }, ) } fn handle_navigation(&mut self, intent: NavigationIntent) -> Task { match intent { NavigationIntent::OpenEntry(entry) => { self.request_action(PendingAction::OpenEntry(entry)) } NavigationIntent::None => iced::widget::operation::snap_to( sidebar_scroll_id(), scrollable::RelativeOffset { x: 0.0, y: self.navigation.selected_ratio(), }, ), } } fn request_action(&mut self, action: PendingAction) -> Task { if self.saving { self.after_save = Some(action); self.status = "Waiting for the active save to finish…".to_owned(); return Task::none(); } if dirty_decision(self.editor.as_ref()) == DirtyDecision::Confirm { if let Some(entry) = self.editor.as_ref().map(EntryEditor::entry) { let _restored = self.navigation.select_entry_path(&entry); } self.confirmation = Some(action); Task::none() } else { self.execute_action(action) } } fn execute_action(&mut self, action: PendingAction) -> Task { match action { PendingAction::OpenEntry(entry) | PendingAction::Reload(entry) => { self.editor = None; self.content_mode = ContentMode::Viewer; self.begin_open(entry) } PendingAction::CloseWindow(id) => { self.sensitive.clear(); window::close(id) } } } fn begin_open(&mut self, entry: String) -> Task { let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { self.after_authentication = Some(PendingAction::OpenEntry(entry)); return self.begin_authentication(); }; self.operation_generation = self.operation_generation.wrapping_add(1); let generation = self.operation_generation; self.status = format!("Opening {entry}…"); Task::perform( async move { let result = load_document(&storage, &entry, handle); let completion = Arc::new(Mutex::new(Some(result))); (entry, completion) }, move |(entry, completion)| Message::OpenFinished { generation, entry, completion, }, ) } fn begin_authentication(&mut self) -> Task { let (Some(session), Some(key)) = (self.session.clone(), self.key.clone()) else { return Task::none(); }; if matches!(self.authentication, AuthenticationView::Authenticating) { return Task::none(); } self.authentication_generation = self.authentication_generation.wrapping_add(1); let generation = self.authentication_generation; self.authentication = AuthenticationView::Authenticating; self.status = "Waiting for secure-storage authentication…".to_owned(); Task::perform( async move { session .authenticate(&key) .map_err(|error| error.to_string()) }, move |result| Message::AuthenticationFinished { generation, result }, ) } fn begin_save(&mut self) -> Task { self.touch_user_activity(); if self.content_mode != ContentMode::Editor || !authentication_allows_content(&self.authentication) { return Task::none(); } let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else { return Task::none(); }; let Some(editor) = self.editor.take() else { return Task::none(); }; if !editor.is_dirty() { self.editor = Some(editor); self.status = "The document has no changes to save.".to_owned(); return Task::none(); } self.operation_generation = self.operation_generation.wrapping_add(1); let generation = self.operation_generation; self.saving = true; self.status = format!("Saving {}…", editor.entry()); Task::perform( async move { let result = storage.save_active_document(&handle, editor.document()); Arc::new(Mutex::new(Some((editor, result)))) }, move |completion| Message::SaveFinished { generation, completion, }, ) } fn edit(&mut self, operation: impl FnOnce(&mut EntryEditor) -> Result<(), DocumentError>) { if self.content_mode != ContentMode::Editor || !authentication_allows_content(&self.authentication) { return; } let Some(editor) = self.editor.as_mut() else { return; }; if let Err(error) = operation(editor) { self.status = error.to_string(); } else { self.status = format!("Editing {} (unsaved changes)", editor.entry()); self.conflict = false; } } fn generate(&mut self, id: EntryFieldId) { match GeneratorConfig::pass_defaults().generate_secret(None, false) { Ok(password) => self.edit(|editor| editor.replace_value(id, password)), Err(error) => self.status = format!("Password generation failed: {error}"), } } fn navigate_viewer(&mut self, navigation: NavigationKey) -> Task { if !authentication_allows_content(&self.authentication) { return Task::none(); } let Some(editor) = self.editor.as_mut() else { return Task::none(); }; match navigation { NavigationKey::Previous => editor.navigate(FieldNavigation::Previous), NavigationKey::Next => editor.navigate(FieldNavigation::Next), NavigationKey::First => editor.navigate(FieldNavigation::First), NavigationKey::Last => editor.navigate(FieldNavigation::Last), NavigationKey::Activate => { if let Some(id) = editor.focused() && editor.document().field(id).is_some_and(|field| { field.metadata().sensitivity() == EntrySensitivity::Sensitive }) { let _ignored = editor.toggle_reveal(id); self.status = if editor.is_revealed(id) { "Sensitive value revealed by explicit keyboard action.".to_owned() } else { "Sensitive value hidden.".to_owned() }; } } NavigationKey::Collapse | NavigationKey::Expand => {} } iced::widget::operation::snap_to( viewer_scroll_id(), scrollable::RelativeOffset { x: 0.0, y: editor.focused_ratio(), }, ) } fn touch_user_activity(&mut self) { if let Some(handle) = &self.handle { match handle .touch_user_activity() .and_then(|()| handle.remaining_time()) { Ok(remaining) => { self.authentication = AuthenticationView::Unlocked(remaining); } Err(error) => self.authentication_lost(error.to_string()), } } } fn authentication_lost(&mut self, reason: String) { self.operation_generation = self.operation_generation.wrapping_add(1); self.handle = None; self.sensitive.clear(); self.editor = None; self.content_mode = ContentMode::Viewer; self.saving = false; self.confirmation = None; self.after_save = None; self.after_authentication = None; self.generate_confirmation = None; self.conflict = false; self.authentication = AuthenticationView::Locked; self.status = reason; } fn subscription(&self) -> Subscription { let mut subscriptions = vec![ time::every(Duration::from_secs(1)).map(|_| Message::Tick), event::listen_with(|event, _status, _window| event_message(&event)), window::close_requests().map(Message::RequestClose), ]; #[cfg(target_os = "macos")] subscriptions.push(time::every(Duration::from_millis(50)).map(|_| Message::PollNativeMenu)); Subscription::batch(subscriptions) } fn view(&self) -> Element<'_, Message> { #[cfg(target_os = "macos")] if let Some(menu) = &self.native_menu { menu.sync(self.action_context()); } if let Some(action) = &self.confirmation { return confirmation_view(action); } if let Some(id) = self.generate_confirmation { return generate_confirmation_view(id); } if let Some(utility) = self.utility { return utility_view(self, utility); } let authentication = match &self.authentication { AuthenticationView::Loading => "Loading".to_owned(), AuthenticationView::Locked => "Locked".to_owned(), AuthenticationView::Authenticating => "Authenticating".to_owned(), AuthenticationView::Unlocked(remaining) => { format!("Unlocked · {}s", remaining.as_secs()) } AuthenticationView::Unavailable(error) => format!("Unavailable · {error}"), }; let panes = pane_grid(&self.panes, |_pane, kind, _maximized| { pane_grid::Content::new(match kind { PaneKind::Sidebar => sidebar_view( &self.navigation, &self.tree_state, self.pane_focus == PaneFocus::Sidebar, ), PaneKind::Content => content_view(self), }) }) .spacing(1) .min_size(220) .on_resize(8, Message::PaneResized); container( column![ row![ text(authentication), text(&self.status).size(14), text("Tab changes pane focus").size(12), ] .spacing(16) .padding(10), platform_menu_bar(self), panes, ] .height(Length::Fill), ) .width(Length::Fill) .height(Length::Fill) .into() } } fn sidebar_scroll_id() -> iced::widget::Id { iced::widget::Id::new("desktop-navigation-tree") } fn viewer_scroll_id() -> iced::widget::Id { iced::widget::Id::new("desktop-entry-viewer") } fn platform_menu_bar(app: &App) -> Element<'_, Message> { if cfg!(target_os = "macos") { return container(row![]).height(Length::Fixed(0.0)).into(); } let mut headers = row![].spacing(2).padding([0, 8]); for group in MenuGroup::ALL { headers = headers.push( button(group.label()) .on_press(Message::ToggleMenu(group)) .style(if app.open_menu == Some(group) { button::primary } else { button::text }), ); } let mut menu = column![headers].spacing(4); if let Some(group) = app.open_menu { let context = app.action_context(); let mut actions = row![].spacing(4).padding([6, 8]); for spec in action::actions_in(group) { let label = action::shortcut_label(spec.action).map_or_else( || spec.label.to_owned(), |key| format!("{} {key}", spec.label), ); let item = button(text(label)); actions = actions.push(if action::enabled(spec.action, context) { item.on_press(Message::Action(spec.action)) } else { item }); } menu = menu.push( scrollable(actions).direction(scrollable::Direction::Horizontal( scrollable::Scrollbar::default(), )), ); } container(menu).width(Length::Fill).into() } fn utility_view(app: &App, utility: UtilityView) -> Element<'_, Message> { let mut content = column![].spacing(10); match utility { UtilityView::About => { content = content .push(text(ironstorage::PRODUCT_NAME).size(28)) .push(text(format!("Version {}", env!("CARGO_PKG_VERSION")))) .push(text("A native, pass-compatible password-store client.")); } UtilityView::Settings => { content = content.push(text("Settings").size(28)); if let Some(storage) = &app.storage { content = content .push(text(format!( "Authentication inactivity timeout: {} seconds", storage.authentication_timeout().duration().as_secs() ))) .push(text(format!( "Clipboard cleanup timeout: {} seconds", storage.clipboard_timeout().duration().as_secs() ))) .push(text("Values come from the shared validated configuration.")); } else { content = content.push(text("Shared configuration is still loading.")); } } UtilityView::Help => { content = content.push(text("Keyboard shortcuts").size(28)); for spec in action::ACTIONS { if let Some(shortcut) = action::shortcut_label(spec.action) { content = content.push(text(format!("{shortcut} {}", spec.label))); } } } } container( column![ scrollable(content).height(Length::Fill), button("Done").on_press(Message::DismissUtility), ] .spacing(12) .padding(20), ) .width(Length::Fill) .height(Length::Fill) .into() } fn sidebar_view<'a>( navigation: &'a NavigationTree, state: &'a TreeState, focused: bool, ) -> Element<'a, Message> { let mut rows = column![ row![ text(if focused { "Password Store · focused" } else { "Password Store" }) .size(20), button("Refresh").on_press(Message::Action(UiAction::Refresh)), ] .spacing(8) ] .spacing(4) .padding(10); match state { TreeState::Loading => { rows = rows.push(text("Loading password-store tree…").size(13)); } TreeState::Error(error) => { rows = rows.push(text(format!("Tree unavailable: {error}")).size(13)); } TreeState::Empty => { rows = rows.push(text("This password store is empty.").size(13)); } TreeState::Ready => {} } let selected = navigation.selected(); for node in navigation.rows() { let marker = if node.id.is_directory() { if node.expanded { "▾" } else { "▸" } } else { "•" }; let mut flags = Vec::new(); if node.indicators.has_conflict() { flags.push("conflict"); } if node.indicators.is_changed() { flags.push("changed"); } if node.indicators.is_locked() { flags.push("locked"); } let suffix = if flags.is_empty() { String::new() } else { format!(" · {}", flags.join(", ")) }; let selected = selected == Some(&node.id); let item = button( row![ container(text("")).width(Length::Fixed((node.depth * 16) as f32)), text(marker), text(format!("{}{}", node.name, suffix)), ] .spacing(5), ) .width(Length::Fill) .on_press(Message::SidebarActivate(node.id)) .style(if selected { button::primary } else { button::text }); rows = rows.push(item); } scrollable(rows) .id(sidebar_scroll_id()) .height(Length::Fill) .into() } fn content_view(app: &App) -> Element<'_, Message> { let open = row![ text(if app.pane_focus == PaneFocus::Content { "Content · focused" } else { "Content" }) .size(20), text_input("Entry path", &app.entry_path) .on_input(Message::EntryPathChanged) .on_submit(Message::Action(UiAction::OpenEntry)), button("Open").on_press(Message::Action(UiAction::OpenEntry)), button("Reload").on_press(Message::Action(UiAction::ReloadEntry)), button("Lock").on_press(Message::Action(UiAction::Lock)), ] .spacing(8); let body: Element<'_, Message> = if !authentication_allows_content(&app.authentication) { container(text( "Protected entry content is locked. Select an entry to authenticate and open it.", )) .center(Length::Fill) .into() } else if app.saving { container(text("Saving without discarding the draft…")) .center(Length::Fill) .into() } else if let Some(editor) = &app.editor { match app.content_mode { ContentMode::Viewer => viewer_view(editor), ContentMode::Editor => editor_view(editor, app.conflict), } } else { container(text( "Select an entry in the navigation tree. Entry names remain visible while locked; protected content authenticates only when opened.", )) .center(Length::Fill) .into() }; container(column![open, body].spacing(12).padding(12)) .width(Length::Fill) .height(Length::Fill) .into() } fn authentication_allows_content(authentication: &AuthenticationView) -> bool { matches!(authentication, AuthenticationView::Unlocked(_)) } #[derive(Debug, Eq, PartialEq)] enum ViewerValue<'a> { Masked, Text(&'a str), Unavailable, } fn viewer_value(field: &EntryField, revealed: bool) -> ViewerValue<'_> { if field.metadata().sensitivity() == EntrySensitivity::Sensitive && !revealed { ViewerValue::Masked } else { std::str::from_utf8(field.value()) .map(ViewerValue::Text) .unwrap_or(ViewerValue::Unavailable) } } fn viewer_label(field: &EntryField, index: usize) -> String { let label = field.metadata().name().map_or_else( || match field.metadata().kind() { EntryFieldKind::Password => "Password".to_owned(), EntryFieldKind::Username => "Username".to_owned(), EntryFieldKind::Email => "Email".to_owned(), EntryFieldKind::Url => "URL".to_owned(), EntryFieldKind::OtpUri => "One-time password".to_owned(), EntryFieldKind::Field => "Field".to_owned(), EntryFieldKind::Note => "Note".to_owned(), EntryFieldKind::Blank => "Blank line".to_owned(), }, str::to_owned, ); format!("{label} · line {}", index + 1) } fn viewer_diagnostic(diagnostic: EntryFieldDiagnostic) -> &'static str { match diagnostic { EntryFieldDiagnostic::MalformedOtpUri => { "Malformed OTP URI preserved losslessly; OTP metadata is unavailable." } EntryFieldDiagnostic::NonUtf8Value => { "Non-UTF-8 value preserved losslessly; text display is unavailable." } } } fn viewer_view(editor: &EntryEditor) -> Element<'_, Message> { let mut rows = column![ row![ text(editor.entry()).size(22), button("Edit entry").on_press(Message::Action(UiAction::EditEntry)), ] .spacing(8), text("Use Up/Down/Home/End to select fields; Enter reveals or hides a selected sensitive value.") .size(12), ] .spacing(12); for (index, field) in editor.fields().iter().enumerate() { let id = field.id(); let label = viewer_label(field, index); let selected = editor.focused() == Some(id); let revealed = editor.is_revealed(id); let value = match viewer_value(field, revealed) { ViewerValue::Masked => "••••••••", ViewerValue::Text("") => "(empty)", ViewerValue::Text(value) => value, ViewerValue::Unavailable => "(binary value)", }; let mut actions = row![ button(text(format!("Copy {label}"))) .on_press(Message::FieldAction(id, UiAction::CopyField)), ] .spacing(6); if field.metadata().sensitivity() == EntrySensitivity::Sensitive { actions = actions.push( button(text(format!( "{} {label}", if revealed { "Hide" } else { "Reveal" } ))) .on_press(Message::FieldAction(id, UiAction::ToggleReveal)), ); } let mut field_view = column![ button(text(format!( "{} {label}", if selected { "●" } else { "○" } ))) .on_press(Message::SelectField(id)) .style(if selected { button::primary } else { button::text }), text(value), actions, ] .spacing(5); if let Some(otp) = field.metadata().otp() { let cadence = otp.period().map_or_else( || format!("counter {}", otp.counter().unwrap_or_default()), |period| format!("{period}s period"), ); field_view = field_view.push(text(format!( "{:?} · {} · {} · {:?} · {} digits · {cadence}", otp.kind(), otp.issuer().unwrap_or("unknown issuer"), otp.account(), otp.algorithm(), otp.digits(), ))); } if let Some(diagnostic) = field.metadata().diagnostic() { field_view = field_view.push(text(viewer_diagnostic(diagnostic)).size(12)); } rows = rows.push(container(field_view).padding(10).width(Length::Fill)); } scrollable(rows) .id(viewer_scroll_id()) .height(Length::Fill) .into() } fn dirty_decision(editor: Option<&EntryEditor>) -> DirtyDecision { if editor.is_some_and(EntryEditor::is_dirty) { DirtyDecision::Confirm } else { DirtyDecision::Execute } } fn tree_state_from_result(result: Result) -> TreeState { match result { Ok(true) => TreeState::Empty, Ok(false) => TreeState::Ready, Err(error) => TreeState::Error(error), } } fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> { let mut fields = column![ row![ text(format!( "{}{}", editor.entry(), if editor.is_dirty() { " •" } else { "" } )) .size(22), button("Save").on_press(Message::Action(UiAction::Save)), button("Add line").on_press(Message::AddAfter(None)), ] .spacing(8) ] .spacing(10); if conflict { fields = fields.push( row![ text("The stored entry changed. Reload it or keep the complete local draft."), button("Reload stored entry").on_press(Message::ReloadConflict), button("Keep draft").on_press(Message::KeepConflictDraft), ] .spacing(8), ); } for field in editor.fields() { let id = field.id(); let sensitive = field.metadata().sensitivity() == EntrySensitivity::Sensitive; let value = std::str::from_utf8(field.contents().expose()).ok(); let label = field .metadata() .name() .map(str::to_owned) .unwrap_or_else(|| format!("{:?}", field.metadata().kind())); let input = text_input( if value.is_some() { "Entry line" } else { "Non-UTF-8 line preserved" }, value.unwrap_or(""), ) .secure(sensitive && !editor.is_revealed(id)) .on_input_maybe( value .is_some() .then_some(move |value| Message::FieldChanged(id, Zeroizing::new(value))), ) .on_submit(Message::AddAfter(Some(id))); let mut actions = row![ button("↑").on_press(Message::MoveUp(id)), button("↓").on_press(Message::MoveDown(id)), button("Add below").on_press(Message::AddAfter(Some(id))), button("Remove").on_press(Message::Remove(id)), button("Copy value").on_press(Message::FieldAction(id, UiAction::CopyEditedField)), ] .spacing(6); if sensitive { actions = actions.push( button(if editor.is_revealed(id) { "Hide" } else { "Reveal" }) .on_press(Message::FieldAction(id, UiAction::ToggleReveal)), ); } if sensitive && field.metadata().kind() != EntryFieldKind::OtpUri { actions = actions.push(button("Generate").on_press(Message::RequestGenerate(id))); } fields = fields.push(column![text(label).size(14), input, actions].spacing(4)); } scrollable(fields).height(Length::Fill).into() } fn confirmation_view(action: &PendingAction) -> Element<'_, Message> { let description = match action { PendingAction::OpenEntry(entry) => format!("Open {entry}"), PendingAction::Reload(entry) => format!("Reload {entry}"), PendingAction::CloseWindow(_) => "Close IronStorage".to_owned(), }; container( column![ text("Save changes?").size(26), text(format!( "{description} would discard the current structured editor draft." )), row![ button("Save").on_press(Message::ConfirmSave), button("Discard").on_press(Message::ConfirmDiscard), button("Cancel").on_press(Message::CancelDiscard), ] .spacing(8), ] .spacing(12), ) .center(Length::Fill) .into() } fn generate_confirmation_view(id: EntryFieldId) -> Element<'static, Message> { container( column![ text("Replace the current value?").size(26), text("The generated password replaces only this storage-provided field value."), row![ button("Replace").on_press(Message::ConfirmGenerate), button("Cancel").on_press(Message::CancelGenerate), ] .spacing(8), text(format!("Field {}", id.value())).size(12), ] .spacing(12), ) .center(Length::Fill) .into() } 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( storage: &DesktopStorage, entry: &str, mut handle: NativeAuthenticationHandle, ) -> Result { storage .open_document(entry, &mut handle) .map_err(|error| error.to_string()) } #[cfg(test)] fn save_document( storage: &DesktopStorage, editor: &EntryEditor, ) -> Result { storage.save_document(editor.document()) } async fn copy_to_clipboard( value: SecretBytes, timeout: ironstorage::presentation::ClipboardTimeout, cancel: Arc, ) -> Result { let mut clipboard = NativeClipboardManager::system(timeout).map_err(|error| error.to_string())?; let disposition = clipboard .copy_with(&value, |duration| { let deadline = Instant::now() + duration; while Instant::now() < deadline { if cancel.load(Ordering::Acquire) { return ClipboardWait::Cancelled; } thread::sleep( deadline .saturating_duration_since(Instant::now()) .min(Duration::from_millis(50)), ); } ClipboardWait::Elapsed }) .map_err(|error| error.to_string())?; Ok(format!("Clipboard cleanup complete: {disposition:?}")) } fn poll_lease( session: &AuthenticationSession, handle: &mut Option>, sensitive: &mut SensitiveUiState, ) -> Result { if session.expire()? { *handle = None; sensitive.clear(); return Ok(LeasePoll::Expired); } handle .as_ref() .map(AuthenticationHandle::remaining_time) .transpose() .map(|remaining| remaining.map_or(LeasePoll::Idle, LeasePoll::Active)) } fn event_message(event: &Event) -> Option { if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event { if let Some(action) = action::shortcut_action(key, *modifiers) { return Some(Message::Action(action)); } let navigation = match key.as_ref() { keyboard::Key::Named(keyboard::key::Named::ArrowUp) => NavigationKey::Previous, keyboard::Key::Named(keyboard::key::Named::ArrowDown) => NavigationKey::Next, keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => NavigationKey::Collapse, keyboard::Key::Named(keyboard::key::Named::ArrowRight) => NavigationKey::Expand, keyboard::Key::Named(keyboard::key::Named::Enter) => NavigationKey::Activate, keyboard::Key::Named(keyboard::key::Named::Home) => NavigationKey::First, keyboard::Key::Named(keyboard::key::Named::End) => NavigationKey::Last, _ => return is_deliberate_activity(event).then_some(Message::UserActivity), }; return Some(Message::SidebarNavigate(navigation)); } is_deliberate_activity(event).then_some(Message::UserActivity) } fn is_deliberate_activity(event: &Event) -> bool { matches!( event, Event::Keyboard(keyboard::Event::KeyPressed { .. }) | Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. }) | Event::Touch( touch::Event::FingerPressed { .. } | touch::Event::FingerMoved { .. } | touch::Event::FingerLifted { .. } ) ) } fn take_completion(completion: &Arc>>) -> Option { completion.lock().ok()?.take() } #[cfg(test)] mod tests { use std::{ collections::BTreeMap, fs, path::Path, sync::{Arc, Mutex}, }; use iced::{Point, window}; use ironstorage::{ authentication::{AuthenticationTimeout, DEFAULT_AUTHENTICATION_TIMEOUT}, crypto::{KeyStore, SecretProvider, SecretProviderError}, repository::EntryPath, secret_store::{ SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, SecretReference, SecretStore, SecretStoreError, }, }; use super::*; #[derive(Clone, Default)] struct ManualClock(Arc>); impl ManualClock { fn advance(&self, duration: Duration) { *self.0.lock().expect("test clock") += duration; } } impl AuthenticationClock for ManualClock { fn now(&self) -> Duration { *self.0.lock().expect("test clock") } } #[derive(Clone, Default)] struct MemoryBackend(Arc>>); impl SecretStoreBackend for MemoryBackend { fn create( &self, locator: &SecretLocator, _protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { self.0 .lock() .expect("test backend") .insert(locator.clone(), SecretBytes::new(value.to_vec())); Ok(()) } fn retrieve( &self, locator: &SecretLocator, _protection: SecretProtection, ) -> Result { self.0 .lock() .expect("test backend") .get(locator) .map(|value| SecretBytes::new(value.expose().to_vec())) .ok_or(SecretStoreError::Missing) } fn replace( &self, locator: &SecretLocator, _protection: SecretProtection, value: &[u8], ) -> Result<(), SecretStoreError> { self.create(locator, SecretProtection::DeviceUnlocked, value) } fn delete( &self, locator: &SecretLocator, _protection: SecretProtection, ) -> Result<(), SecretStoreError> { self.0.lock().expect("test backend").remove(locator); Ok(()) } fn lock(&self) -> Result<(), SecretStoreError> { Ok(()) } fn unlock(&self) -> Result<(), SecretStoreError> { Ok(()) } } struct FixtureSecrets; impl SecretProvider for FixtureSecrets { fn secret_for(&mut self, _key: &KeyInfo) -> Result { Ok(SecretBytes::new(b"fixture-alice-passphrase".to_vec())) } } fn session( timeout: Duration, ) -> ( AuthenticationSession, KeyInfo, ManualClock, ) { let mut keys = KeyStore::new(); let [key] = keys .import(include_bytes!( "../../../crates/storage/tests/fixtures/compatibility/keys/alice-secret.asc" )) .expect("fixture key") .try_into() .expect("one fixture key"); let backend = MemoryBackend::default(); let store = SecretStore::new( backend.clone(), SecretCachePolicy::Disabled, SecretProtectionPolicy::device_unlocked(), ); store.unlock().expect("unlock fixture store"); store .create( &SecretReference::openpgp_passphrase(key.fingerprint().as_str()) .expect("fixture reference"), SecretBytes::new(b"fixture-alice-passphrase".to_vec()), ) .expect("provision fixture passphrase"); store.lock().expect("lock fixture store"); let clock = ManualClock::default(); let session = AuthenticationSession::with_clock( backend, SecretProtectionPolicy::device_unlocked(), AuthenticationTimeout::new(timeout).expect("valid timeout"), clock.clone(), ); (session, key, clock) } 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"); fs::create_dir_all(&vault).expect("vault"); fs::create_dir_all(&native).expect("native"); let keys = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../crates/storage/tests/fixtures/compatibility/keys"); fs::write( vault.join(".gpg-id"), b"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\n", ) .expect("recipient policy"); let config_path = temporary.path().join("config.toml"); fs::write( &config_path, format!( "vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n", vault, keys ), ) .expect("configuration"); let storage = DesktopStorage::load(Some(&config_path)).expect("load configuration"); (temporary, storage) } fn empty_editor(storage: &DesktopStorage, entry: &str) -> EntryEditor { let document = storage .open_document(entry, &mut FixtureSecrets) .expect("document"); EntryEditor::new(document) } fn test_app(editor: Option) -> App { App { authentication: AuthenticationView::Locked, storage: None, session: None, key: None, handle: None, sensitive: SensitiveUiState::default(), authentication_generation: 0, operation_generation: 0, tree_generation: 0, panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split { axis: pane_grid::Axis::Vertical, ratio: 0.28, a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)), b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)), }), pane_focus: PaneFocus::Sidebar, navigation: NavigationTree::default(), tree_state: TreeState::Loading, after_authentication: None, entry_path: String::new(), editor, content_mode: ContentMode::Viewer, saving: false, confirmation: None, after_save: None, generate_confirmation: None, conflict: false, status: String::new(), open_menu: None, utility: None, #[cfg(target_os = "macos")] native_menu: None, } } #[test] fn structured_fields_save_round_trip_and_stale_drafts_remain_recoverable() { 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"); editor.add_after(Some(password)).expect("username line"); let username = editor.fields()[1].id(); editor .update_raw(username, b"username: alice") .expect("username"); editor.add_after(Some(username)).expect("first note line"); let first_note = editor.fields()[2].id(); editor .update_raw(first_note, b"first note line") .expect("first note"); editor .add_after(Some(first_note)) .expect("second note line"); let second_note = editor.fields()[3].id(); editor .update_raw(second_note, b"second note line") .expect("second note"); editor .add_after(Some(second_note)) .expect("unknown field line"); let unknown = editor.fields()[4].id(); editor .update_raw(unknown, b"custom-field: opaque") .expect("unknown field"); editor.move_up(unknown).expect("reorder unknown field"); editor.add_after(None).expect("temporary line"); let temporary = editor.fields().last().expect("temporary field").id(); editor.remove(temporary).expect("remove temporary line"); let expected = b"password\nusername: alice\nfirst note line\ncustom-field: opaque\nsecond note line"; assert_eq!(editor.document().serialize().expose(), expected); save_document(&storage, &editor).expect("initial save"); let reopened = empty_editor(&storage, "documents/editable"); assert_eq!(reopened.document().serialize().expose(), expected); 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(&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(&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" ); } #[test] fn viewer_covers_lossless_fields_navigation_and_explicit_sensitive_actions() { let (_temporary, storage) = fixture_storage(); let mut editor = empty_editor(&storage, "documents/viewer"); for value in [ b"password".as_slice(), b"username: alice", b"custom: first", b"custom: second", b"first note", b"second note", b"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example", b"otpauth://broken", b"binary: \xff", ] { let after = editor.fields().last().map(EntryField::id); editor.add_after(after).expect("add viewer field"); let id = editor.fields().last().expect("new viewer field").id(); editor.update_raw(id, value).expect("populate viewer field"); } let fields = editor.fields(); assert_eq!(fields[0].metadata().kind(), EntryFieldKind::Password); assert_eq!(fields[1].metadata().kind(), EntryFieldKind::Username); assert_eq!(fields[2].metadata().name(), Some("custom")); assert_eq!(fields[3].metadata().name(), Some("custom")); assert_ne!(viewer_label(&fields[2], 2), viewer_label(&fields[3], 3)); assert_eq!(fields[4].metadata().kind(), EntryFieldKind::Note); assert_eq!(fields[5].metadata().kind(), EntryFieldKind::Note); assert!(fields[6].metadata().otp().is_some()); assert_eq!( fields[7].metadata().diagnostic(), Some(EntryFieldDiagnostic::MalformedOtpUri) ); assert_eq!( fields[8].metadata().diagnostic(), Some(EntryFieldDiagnostic::NonUtf8Value) ); let password = fields[0].id(); assert_eq!(viewer_value(&fields[0], false), ViewerValue::Masked); editor.toggle_reveal(password).expect("reveal password"); assert_eq!( viewer_value(&editor.fields()[0], editor.is_revealed(password)), ViewerValue::Text("password") ); editor.navigate(FieldNavigation::First); assert_eq!(editor.focused(), Some(password)); editor.navigate(FieldNavigation::Next); assert_eq!(editor.focused(), Some(editor.fields()[1].id())); editor.navigate(FieldNavigation::Last); assert_eq!(editor.focused(), Some(editor.fields()[8].id())); assert!(!authentication_allows_content(&AuthenticationView::Locked)); assert!(authentication_allows_content( &AuthenticationView::Unlocked(Duration::from_secs(1)) )); editor.toggle_reveal(password).expect("hide password"); let mut app = test_app(Some(editor)); let _task = app.update(Message::ToggleReveal(password)); assert!( !app.editor .as_ref() .expect("locked document fixture") .is_revealed(password) ); let _task = app.update(Message::BeginEdit); assert_eq!(app.content_mode, ContentMode::Viewer); app.authentication = AuthenticationView::Unlocked(Duration::from_secs(1)); let _task = app.update(Message::ToggleReveal(password)); assert!( app.editor .as_ref() .expect("unlocked document fixture") .is_revealed(password) ); let _task = app.update(Message::BeginEdit); assert_eq!(app.content_mode, ContentMode::Editor); app.authentication_lost("locked".to_owned()); assert!(app.editor.is_none()); } #[test] fn every_destructive_path_uses_the_same_save_discard_cancel_guard() { 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 [ PendingAction::OpenEntry("other".to_owned()), PendingAction::Reload("draft".to_owned()), PendingAction::CloseWindow(window::Id::unique()), ] { assert!(matches!( action, PendingAction::OpenEntry(_) | PendingAction::Reload(_) | PendingAction::CloseWindow(_) )); assert_eq!(dirty_decision(Some(&editor)), DirtyDecision::Confirm); } assert_eq!(dirty_decision(None), DirtyDecision::Execute); let draft = editor.document().serialize().expose().to_vec(); let mut app = test_app(Some(editor)); let draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft path")); let other_id = TreeNodeId::Entry(EntryPath::parse("other").expect("other path")); app.navigation.replace_test_nodes(vec![ navigation::TestNode { id: draft_id, name: "draft".to_owned(), children: Vec::new(), }, navigation::TestNode { id: other_id.clone(), name: "other".to_owned(), children: Vec::new(), }, ]); assert!(app.navigation.select_entry_path("draft")); let intent = app.navigation.activate(other_id); let _task = app.handle_navigation(intent); assert_eq!( app.navigation.selected().map(TreeNodeId::path), Some(Path::new("draft")) ); let _task = app.update(Message::CancelDiscard); assert!(app.confirmation.is_none()); assert_eq!( app.editor .as_ref() .expect("cancel retains editor") .document() .serialize() .expose(), draft ); let _task = app.request_action(PendingAction::OpenEntry("other".to_owned())); let _task = app.update(Message::ConfirmDiscard); assert!(app.editor.is_none()); assert_eq!( app.after_authentication, Some(PendingAction::OpenEntry("other".to_owned())) ); } #[test] fn shared_actions_cannot_bypass_authentication_or_dirty_confirmation() { let (_temporary, storage) = fixture_storage(); let mut editor = empty_editor(&storage, "draft"); editor.add_after(None).expect("dirty line"); let mut app = test_app(Some(editor)); app.content_mode = ContentMode::Editor; assert!(!action::enabled(UiAction::Save, app.action_context())); let _task = app.invoke_action(UiAction::Save); assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty)); app.authentication = AuthenticationView::Unlocked(Duration::from_secs(60)); assert!(action::enabled(UiAction::Save, app.action_context())); let id = window::Id::unique(); let _task = app.update(Message::WindowResolved(UiAction::CloseWindow, Some(id))); assert_eq!(app.confirmation, Some(PendingAction::CloseWindow(id))); assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty)); } #[test] fn lock_and_expiry_drop_the_complete_editor_and_clipboard_state() { 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))); app.authentication_lost("locked".to_owned()); assert!(app.editor.is_none()); assert!(app.sensitive.clipboard_cancel.is_none()); let (generation, cancel) = app.sensitive.begin_copy(); app.sensitive.clear(); assert!(cancel.load(Ordering::Acquire)); assert!(!app.sensitive.finish_copy(generation)); let timeout = DEFAULT_AUTHENTICATION_TIMEOUT; let (session, key, clock) = session(timeout); let mut handle = Some(session.authenticate(&key).expect("authenticate")); let mut sensitive = SensitiveUiState { clipboard_cancel: Some(Arc::new(AtomicBool::new(false))), clipboard_generation: 0, }; clock.advance(timeout); assert_eq!( poll_lease(&session, &mut handle, &mut sensitive).expect("poll"), LeasePoll::Expired ); assert!(handle.is_none()); assert!(sensitive.clipboard_cancel.is_none()); } #[test] fn deliberate_input_and_primary_save_are_distinct_from_passive_events() { assert!(!is_deliberate_activity(&Event::Window( window::Event::Focused ))); assert!(!is_deliberate_activity(&Event::Mouse( mouse::Event::CursorMoved { position: Point::ORIGIN, } ))); let click = Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)); assert!(matches!(event_message(&click), Some(Message::UserActivity))); let save = Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Character("s".into()), modified_key: keyboard::Key::Character("s".into()), physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyS), location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::COMMAND, text: Some("s".into()), repeat: false, }); assert!(matches!( event_message(&save), Some(Message::Action(UiAction::Save)) )); let tab = Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Tab), modified_key: keyboard::Key::Named(keyboard::key::Named::Tab), physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Tab), location: keyboard::Location::Standard, modifiers: keyboard::Modifiers::NONE, text: None, repeat: false, }); assert!(matches!( event_message(&tab), Some(Message::Action(UiAction::TogglePaneFocus)) )); } #[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); app.tree_generation = 7; let completion = Arc::new(Mutex::new(Some(Err("injected tree failure".to_owned())))); let _task = app.update(Message::TreeLoaded { generation: 7, completion, }); assert_eq!( app.tree_state, TreeState::Error("injected tree failure".to_owned()) ); assert!(app.navigation.is_empty()); } }