Files
IronStorage/apps/desktop/src/main.rs

2150 lines
78 KiB
Rust

#![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<Mutex<Option<Result<EntryDocument, String>>>>;
type SaveCompletion = Arc<Mutex<Option<(EntryEditor, Result<WriteOutcome, DesktopError>)>>>;
type TreeCompletion = Arc<Mutex<Option<Result<TreeModel, String>>>>;
#[derive(Clone)]
enum Message {
Action(UiAction),
FieldAction(EntryFieldId, UiAction),
ToggleMenu(MenuGroup),
DismissUtility,
WindowResolved(UiAction, Option<window::Id>),
#[cfg(target_os = "macos")]
PollNativeMenu,
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
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<NativeAuthenticationHandle, String>,
},
FieldChanged(EntryFieldId, Zeroizing<String>),
AddAfter(Option<EntryFieldId>),
Remove(EntryFieldId),
MoveUp(EntryFieldId),
MoveDown(EntryFieldId),
BeginEdit,
SelectField(EntryFieldId),
ToggleReveal(EntryFieldId),
RequestGenerate(EntryFieldId),
ConfirmGenerate,
CancelGenerate,
Copy(EntryFieldId),
CopyFinished {
generation: u64,
result: Result<String, String>,
},
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<Arc<AtomicBool>>,
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<AtomicBool>) {
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<DesktopStorage>,
session: Option<NativeAuthenticationSession>,
key: Option<KeyInfo>,
handle: Option<NativeAuthenticationHandle>,
sensitive: SensitiveUiState,
authentication_generation: u64,
operation_generation: u64,
tree_generation: u64,
panes: pane_grid::State<PaneKind>,
pane_focus: PaneFocus,
navigation: NavigationTree,
tree_state: TreeState,
after_authentication: Option<PendingAction>,
entry_path: String,
editor: Option<EntryEditor>,
content_mode: ContentMode,
saving: bool,
confirmation: Option<PendingAction>,
after_save: Option<PendingAction>,
generate_confirmation: Option<EntryFieldId>,
conflict: bool,
status: String,
open_menu: Option<MenuGroup>,
utility: Option<UtilityView>,
#[cfg(target_os = "macos")]
native_menu: Option<NativeMenu>,
}
#[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<Message>) {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<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![
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<EntryDocument, String> {
storage
.open_document(entry, &mut handle)
.map_err(|error| 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(
value: SecretBytes,
timeout: ironstorage::presentation::ClipboardTimeout,
cancel: Arc<AtomicBool>,
) -> Result<String, String> {
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<B: SecretStoreBackend, C: AuthenticationClock>(
session: &AuthenticationSession<B, C>,
handle: &mut Option<AuthenticationHandle<B, C>>,
sensitive: &mut SensitiveUiState,
) -> Result<LeasePoll, AuthenticationError> {
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<Message> {
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<T>(completion: &Arc<Mutex<Option<T>>>) -> Option<T> {
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<Mutex<Duration>>);
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<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
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<SecretBytes, SecretStoreError> {
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<SecretBytes, SecretProviderError> {
Ok(SecretBytes::new(b"fixture-alice-passphrase".to_vec()))
}
}
fn session(
timeout: Duration,
) -> (
AuthenticationSession<MemoryBackend, ManualClock>,
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<EntryEditor>) -> 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());
}
}