2150 lines
78 KiB
Rust
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());
|
|
}
|
|
}
|