7711 lines
296 KiB
Rust
7711 lines
296 KiB
Rust
#![forbid(unsafe_code)]
|
||
#![deny(clippy::disallowed_types)]
|
||
|
||
mod action;
|
||
mod editor;
|
||
mod folder_picker;
|
||
mod launcher;
|
||
#[cfg(target_os = "macos")]
|
||
mod native_menu;
|
||
mod navigation;
|
||
mod palette;
|
||
|
||
use std::{
|
||
num::NonZeroUsize,
|
||
path::PathBuf,
|
||
sync::{
|
||
Arc, Mutex,
|
||
atomic::{AtomicBool, Ordering},
|
||
},
|
||
thread,
|
||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||
};
|
||
|
||
use editor::{EntryEditor, FieldNavigation};
|
||
use iced::{
|
||
Background, Border, Color, Element, Event, Length, Point, Rectangle, Renderer, Size,
|
||
Subscription, Task, Theme, event, keyboard, mouse, time, touch,
|
||
widget::{
|
||
button, canvas, column, container, mouse_area, pane_grid, progress_bar, row, scrollable,
|
||
text, text_editor, text_input, tooltip,
|
||
},
|
||
window,
|
||
};
|
||
use ironstorage::{
|
||
authentication::{
|
||
AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession,
|
||
NativeAuthenticationHandle, NativeAuthenticationSession,
|
||
},
|
||
command::{CopyRequest, FindRequest, GrepRequest, InitRequest, MoveRequest, RemoveRequest},
|
||
crypto::KeyInfo,
|
||
desktop::{
|
||
DesktopError, DesktopErrorKind, DesktopGitOutcome, DesktopGitRequest, DesktopGitResult,
|
||
DesktopMutationRequest, DesktopOtpCode, DesktopOtpMetadata, DesktopOtpMutation,
|
||
DesktopOtpUri, DesktopStorage,
|
||
},
|
||
document::{
|
||
DocumentError, EntryDocument, EntryField, EntryFieldDiagnostic, EntryFieldId,
|
||
EntryFieldKind, EntrySensitivity,
|
||
},
|
||
generate::GeneratorConfig,
|
||
git::{
|
||
GitConflict, GitConflictChoice, GitConflictResolution, GitError, GitOperationControl,
|
||
GitProgressPhase, GitSnapshot,
|
||
},
|
||
kdbx::{KdbxImportMode, KdbxImportRequest},
|
||
mutation::{MutationAction, MutationOutcome, MutationSelection},
|
||
otp::{OtpCodeValidity, OtpKind},
|
||
presentation::{ClipboardWait, NativeClipboardManager, QrMatrix},
|
||
read::{FindResults, GrepResults, TreeModel, TreeNodeId},
|
||
repository::SecretBytes,
|
||
secret_store::SecretStoreBackend,
|
||
write::{OverwriteDecision, WriteOutcome},
|
||
};
|
||
use navigation::{NavigationIntent, NavigationKey, NavigationTree};
|
||
use palette::CommandPalette;
|
||
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>>>>;
|
||
type CreateCompletion = Arc<Mutex<Option<(SecretBytes, Result<EntryDocument, String>)>>>;
|
||
type SearchCompletion = Arc<Mutex<Option<Result<SearchResults, String>>>>;
|
||
type GitCompletion = Arc<Mutex<Option<Result<DesktopGitResult, DesktopError>>>>;
|
||
type GitProgress = Arc<Mutex<Option<GitProgressPhase>>>;
|
||
type OtpCompletion = Arc<Mutex<Option<Result<OtpTaskResult, DesktopError>>>>;
|
||
type SecretInputCompletion = Arc<Mutex<Option<Result<Option<SecretBytes>, String>>>>;
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
struct RecipientSummary {
|
||
directory: String,
|
||
recipients: usize,
|
||
reencrypted: usize,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct RecipientSuccess {
|
||
storage: DesktopStorage,
|
||
key: KeyInfo,
|
||
summary: RecipientSummary,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
enum Message {
|
||
Action(UiAction),
|
||
FieldAction(EntryFieldId, UiAction),
|
||
ToggleMenu(MenuGroup),
|
||
PaletteQueryChanged(String),
|
||
PaletteCancel,
|
||
PaletteInvoke(UiAction),
|
||
DismissUtility,
|
||
WindowResolved(UiAction, Option<window::Id>),
|
||
FolderPicked(Result<Option<PathBuf>, String>),
|
||
SettingsVaultChanged(String),
|
||
SettingsDefaultKeyChanged(String),
|
||
SettingsTimeoutChanged(String),
|
||
PickSettingsVault,
|
||
SettingsVaultPicked(Result<Option<PathBuf>, String>),
|
||
InstallCommandLinks,
|
||
SaveSettings,
|
||
SettingsFinished {
|
||
generation: u64,
|
||
result: Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>,
|
||
},
|
||
VaultSwitched {
|
||
generation: u64,
|
||
result: Box<Result<DesktopStorage, String>>,
|
||
},
|
||
RecipientPathChanged(String),
|
||
ToggleRecipient(usize),
|
||
SelectDefaultRecipient(usize),
|
||
ToggleRecipientConfirmation,
|
||
SubmitRecipient,
|
||
RecipientFinished {
|
||
generation: u64,
|
||
result: Box<Result<RecipientSuccess, String>>,
|
||
},
|
||
NewEntryPathChanged(String),
|
||
ToggleNewEntryGeneration,
|
||
NewEntryLengthChanged(String),
|
||
ToggleNewEntrySymbols,
|
||
SubmitNewEntry,
|
||
CreateFinished {
|
||
generation: u64,
|
||
completion: CreateCompletion,
|
||
},
|
||
SearchQueryChanged(String),
|
||
AddSearchTerm,
|
||
RemoveSearchTerm(usize),
|
||
ToggleSearchCase,
|
||
ToggleSearchInvert,
|
||
ToggleSearchLineNumbers,
|
||
ToggleSearchFixedStrings,
|
||
SubmitSearch,
|
||
SearchFinished {
|
||
generation: u64,
|
||
completion: SearchCompletion,
|
||
},
|
||
ActivateSearchResult(TreeNodeId),
|
||
MutationDestinationChanged(String),
|
||
SelectMutationDestination(String),
|
||
ToggleMutationOverwrite,
|
||
ToggleMutationConfirmation,
|
||
SubmitMutation,
|
||
MutationFinished {
|
||
generation: u64,
|
||
result: Box<Result<MutationOutcome, String>>,
|
||
},
|
||
RunGit(DesktopGitRequest),
|
||
ChooseGitConflict(usize, GitConflictChoice),
|
||
ResolveGitConflicts,
|
||
CancelGit,
|
||
GitFinished {
|
||
generation: u64,
|
||
completion: GitCompletion,
|
||
},
|
||
OtpEntryChanged(String),
|
||
OtpUriChanged(Zeroizing<String>),
|
||
ToggleOtpReplace,
|
||
ToggleOtpRemovalConfirmation,
|
||
PickOtpQr,
|
||
OtpQrPicked(SecretInputCompletion),
|
||
SubmitOtpImport,
|
||
SubmitOtpRemoval,
|
||
RunOtpCode(bool),
|
||
RunOtpUri {
|
||
qr: bool,
|
||
copy: bool,
|
||
},
|
||
ConfirmHotp,
|
||
OtpFinished {
|
||
generation: u64,
|
||
completion: OtpCompletion,
|
||
},
|
||
KdbxSourceChanged(String),
|
||
KdbxKeyFileChanged(String),
|
||
KdbxPasswordChanged(Zeroizing<String>),
|
||
PickKdbxSource,
|
||
KdbxSourcePicked(Result<Option<PathBuf>, String>),
|
||
PickKdbxKeyFile,
|
||
KdbxKeyFilePicked(Result<Option<PathBuf>, String>),
|
||
ToggleKdbxQuickAdd,
|
||
ToggleKdbxConfirmation,
|
||
SubmitKdbxImport,
|
||
KdbxFinished {
|
||
generation: u64,
|
||
result: Box<Result<(ironstorage::kdbx::KdbxImportOutcome, TreeModel), String>>,
|
||
},
|
||
#[cfg(target_os = "macos")]
|
||
PollNativeMenu,
|
||
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
|
||
TreeLoaded {
|
||
generation: u64,
|
||
completion: TreeCompletion,
|
||
},
|
||
SidebarActivate(TreeNodeId),
|
||
SidebarContext(TreeNodeId),
|
||
SidebarContextAction(TreeNodeId, UiAction),
|
||
SidebarNavigate(NavigationKey),
|
||
TogglePaneFocus,
|
||
PaneResized(pane_grid::ResizeEvent),
|
||
OpenEntry,
|
||
OpenFinished {
|
||
generation: u64,
|
||
entry: String,
|
||
completion: OpenCompletion,
|
||
},
|
||
AuthenticationFinished {
|
||
generation: u64,
|
||
result: Result<NativeAuthenticationHandle, String>,
|
||
},
|
||
FieldChanged(EntryFieldId, Zeroizing<String>),
|
||
FieldEdited(EntryFieldId, text_editor::Action),
|
||
AddFieldLine(EntryFieldId, usize),
|
||
AddAfter(Option<EntryFieldId>),
|
||
Remove(EntryFieldId),
|
||
MoveUp(EntryFieldId),
|
||
MoveDown(EntryFieldId),
|
||
BeginEdit,
|
||
SelectField(EntryFieldId),
|
||
RequestGenerate(EntryFieldId),
|
||
GenerateLengthChanged(String),
|
||
ToggleGenerateSymbols,
|
||
ToggleGenerateConfirmation,
|
||
SubmitGenerate,
|
||
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,
|
||
clipboard_deadline: Option<Instant>,
|
||
otp: Option<OtpDisplay>,
|
||
otp_uri: Option<SecretBytes>,
|
||
otp_qr: Option<QrMatrix>,
|
||
}
|
||
|
||
impl SensitiveUiState {
|
||
fn clear(&mut self) {
|
||
self.clipboard_generation = self.clipboard_generation.wrapping_add(1);
|
||
self.cancel_clipboard();
|
||
self.otp = None;
|
||
self.otp_uri = None;
|
||
self.otp_qr = None;
|
||
}
|
||
|
||
fn cancel_clipboard(&mut self) {
|
||
if let Some(cancel) = self.clipboard_cancel.take() {
|
||
cancel.store(true, Ordering::Release);
|
||
}
|
||
self.clipboard_deadline = None;
|
||
}
|
||
|
||
fn begin_copy(&mut self, timeout: Duration) -> (u64, Arc<AtomicBool>) {
|
||
self.clipboard_generation = self.clipboard_generation.wrapping_add(1);
|
||
self.cancel_clipboard();
|
||
let cancel = Arc::new(AtomicBool::new(false));
|
||
self.clipboard_cancel = Some(Arc::clone(&cancel));
|
||
self.clipboard_deadline = Some(Instant::now() + timeout);
|
||
(self.clipboard_generation, cancel)
|
||
}
|
||
|
||
fn finish_copy(&mut self, generation: u64) -> bool {
|
||
if generation != self.clipboard_generation {
|
||
return false;
|
||
}
|
||
self.clipboard_cancel = None;
|
||
self.clipboard_deadline = None;
|
||
true
|
||
}
|
||
|
||
fn clipboard_remaining(&self, now: Instant) -> Option<u64> {
|
||
self.clipboard_deadline.map(|deadline| {
|
||
let remaining = deadline.saturating_duration_since(now);
|
||
remaining
|
||
.as_secs()
|
||
.saturating_add(u64::from(remaining.subsec_nanos() != 0))
|
||
})
|
||
}
|
||
}
|
||
|
||
struct App {
|
||
authentication: AuthenticationView,
|
||
storage: Option<DesktopStorage>,
|
||
session: Option<NativeAuthenticationSession>,
|
||
key: Option<KeyInfo>,
|
||
handle: Option<NativeAuthenticationHandle>,
|
||
sensitive: SensitiveUiState,
|
||
git_control: Option<GitOperationControl>,
|
||
git_progress: Option<GitProgress>,
|
||
authentication_generation: u64,
|
||
operation_generation: u64,
|
||
tree_generation: u64,
|
||
vault_generation: u64,
|
||
settings_generation: u64,
|
||
workflow_generation: u64,
|
||
otp_generation: u64,
|
||
otp_pending: bool,
|
||
selection_after_refresh: Option<TreeNodeId>,
|
||
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,
|
||
switching_vault: bool,
|
||
confirmation: Option<PendingAction>,
|
||
after_save: Option<PendingAction>,
|
||
generation_form: Option<GenerateForm>,
|
||
conflict: bool,
|
||
status: String,
|
||
open_menu: Option<MenuGroup>,
|
||
utility: Option<UtilityView>,
|
||
context_target: Option<TreeNodeId>,
|
||
palette: CommandPalette,
|
||
#[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,
|
||
}
|
||
|
||
enum UtilityView {
|
||
About,
|
||
Settings(SettingsForm),
|
||
Recipients(RecipientForm),
|
||
NewEntry(NewEntryForm),
|
||
Search(SearchForm),
|
||
Mutation(MutationForm),
|
||
Git(GitForm),
|
||
Otp(OtpForm),
|
||
Kdbx(KdbxForm),
|
||
Help,
|
||
}
|
||
|
||
struct OtpDisplay {
|
||
entry: String,
|
||
code: SecretBytes,
|
||
validity: OtpCodeValidity,
|
||
metadata: DesktopOtpMetadata,
|
||
observed_at: u64,
|
||
}
|
||
|
||
impl OtpDisplay {
|
||
fn remaining_at(&self, unix_seconds: u64) -> Option<u64> {
|
||
self.validity.remaining_at(unix_seconds)
|
||
}
|
||
}
|
||
|
||
struct OtpForm {
|
||
entry: String,
|
||
uri: Zeroizing<String>,
|
||
replace: bool,
|
||
remove_confirmed: bool,
|
||
copy_after_code: bool,
|
||
hotp_confirmation: bool,
|
||
running: bool,
|
||
error: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Eq, PartialEq)]
|
||
struct KdbxForm {
|
||
source: String,
|
||
key_file: String,
|
||
password: Zeroizing<String>,
|
||
quick_add: bool,
|
||
confirmed: bool,
|
||
running: bool,
|
||
error: Option<String>,
|
||
summary: Option<String>,
|
||
}
|
||
|
||
impl std::fmt::Debug for KdbxForm {
|
||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
formatter
|
||
.debug_struct("KdbxForm")
|
||
.field("source", &self.source)
|
||
.field("key_file", &self.key_file)
|
||
.field("password", &"[REDACTED]")
|
||
.field("quick_add", &self.quick_add)
|
||
.field("confirmed", &self.confirmed)
|
||
.field("running", &self.running)
|
||
.field("error", &self.error)
|
||
.field("summary", &self.summary)
|
||
.finish()
|
||
}
|
||
}
|
||
|
||
impl Default for KdbxForm {
|
||
fn default() -> Self {
|
||
Self {
|
||
source: String::new(),
|
||
key_file: String::new(),
|
||
password: Zeroizing::new(String::new()),
|
||
quick_add: false,
|
||
confirmed: false,
|
||
running: false,
|
||
error: None,
|
||
summary: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl KdbxForm {
|
||
fn request(&self) -> Result<(KdbxImportRequest, SecretBytes), String> {
|
||
if self.source.trim().is_empty() {
|
||
return Err("Choose a KDBX database file.".to_owned());
|
||
}
|
||
if !self.confirmed {
|
||
return Err("Confirm the additive import before continuing.".to_owned());
|
||
}
|
||
Ok((
|
||
KdbxImportRequest::new(
|
||
self.source.trim(),
|
||
(!self.key_file.trim().is_empty()).then(|| self.key_file.trim().into()),
|
||
if self.quick_add {
|
||
KdbxImportMode::QuickAdd
|
||
} else {
|
||
KdbxImportMode::AddAndUpdate
|
||
},
|
||
),
|
||
SecretBytes::new(self.password.as_bytes().to_vec()),
|
||
))
|
||
}
|
||
}
|
||
|
||
impl OtpForm {
|
||
fn new(entry: String) -> Self {
|
||
Self {
|
||
entry,
|
||
uri: Zeroizing::new(String::new()),
|
||
replace: false,
|
||
remove_confirmed: false,
|
||
copy_after_code: false,
|
||
hotp_confirmation: false,
|
||
running: false,
|
||
error: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
enum OtpTaskResult {
|
||
Code {
|
||
entry: String,
|
||
copy: bool,
|
||
observed_at: u64,
|
||
outcome: DesktopOtpCode,
|
||
},
|
||
Uri {
|
||
entry: String,
|
||
copy: bool,
|
||
outcome: DesktopOtpUri,
|
||
},
|
||
Imported(DesktopOtpMutation),
|
||
Removed(DesktopOtpMutation),
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct GitConflictSelection {
|
||
conflict: GitConflict,
|
||
choice: Option<GitConflictChoice>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default)]
|
||
struct GitForm {
|
||
snapshot: Option<GitSnapshot>,
|
||
progress: Option<GitProgressPhase>,
|
||
running: bool,
|
||
error: Option<String>,
|
||
conflicts: Vec<GitConflictSelection>,
|
||
}
|
||
|
||
impl GitForm {
|
||
fn resolutions(&self) -> Result<Vec<GitConflictResolution>, String> {
|
||
self.conflicts
|
||
.iter()
|
||
.map(|selection| {
|
||
selection
|
||
.choice
|
||
.map(|choice| {
|
||
GitConflictResolution::new(selection.conflict.path().to_owned(), choice)
|
||
})
|
||
.ok_or_else(|| {
|
||
format!(
|
||
"Choose the local or remote version for {}.",
|
||
selection.conflict.path().display()
|
||
)
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum SearchMode {
|
||
Names,
|
||
Contents,
|
||
}
|
||
|
||
enum SearchResults {
|
||
Names(FindResults),
|
||
Contents(GrepResults),
|
||
}
|
||
|
||
struct SearchForm {
|
||
mode: SearchMode,
|
||
query: String,
|
||
terms: Vec<String>,
|
||
ignore_case: bool,
|
||
invert_match: bool,
|
||
line_numbers: bool,
|
||
fixed_strings: bool,
|
||
running: bool,
|
||
error: Option<String>,
|
||
results: Option<SearchResults>,
|
||
}
|
||
|
||
impl SearchForm {
|
||
fn new(mode: SearchMode) -> Self {
|
||
Self {
|
||
mode,
|
||
query: String::new(),
|
||
terms: Vec::new(),
|
||
ignore_case: false,
|
||
invert_match: false,
|
||
line_numbers: true,
|
||
fixed_strings: false,
|
||
running: false,
|
||
error: None,
|
||
results: None,
|
||
}
|
||
}
|
||
|
||
fn request(&self) -> Result<SearchRequest, String> {
|
||
Ok(match self.mode {
|
||
SearchMode::Names => {
|
||
let mut terms = self.terms.clone();
|
||
if !self.query.is_empty() {
|
||
terms.push(self.query.clone());
|
||
}
|
||
if terms.is_empty() {
|
||
return Err("Enter at least one name search term.".to_owned());
|
||
}
|
||
SearchRequest::Names(FindRequest { terms })
|
||
}
|
||
SearchMode::Contents => SearchRequest::Contents(GrepRequest {
|
||
pattern: self.query.clone(),
|
||
ignore_case: self.ignore_case,
|
||
invert_match: self.invert_match,
|
||
line_number: self.line_numbers,
|
||
fixed_strings: self.fixed_strings,
|
||
}),
|
||
})
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
enum SearchRequest {
|
||
Names(FindRequest),
|
||
Contents(GrepRequest),
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum MutationKind {
|
||
Move,
|
||
Copy,
|
||
Delete,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
struct MutationForm {
|
||
kind: MutationKind,
|
||
source: TreeNodeId,
|
||
destination: String,
|
||
overwrite: bool,
|
||
confirmed: bool,
|
||
running: bool,
|
||
error: Option<String>,
|
||
}
|
||
|
||
impl MutationForm {
|
||
fn new(kind: MutationKind, source: TreeNodeId) -> Self {
|
||
Self {
|
||
destination: source.path().display().to_string(),
|
||
kind,
|
||
source,
|
||
overwrite: false,
|
||
confirmed: false,
|
||
running: false,
|
||
error: None,
|
||
}
|
||
}
|
||
|
||
fn request(&self) -> Result<DesktopMutationRequest, String> {
|
||
let source = self.source.path().display().to_string();
|
||
match self.kind {
|
||
MutationKind::Move | MutationKind::Copy if self.destination == source => {
|
||
Err("Choose a different destination.".to_owned())
|
||
}
|
||
MutationKind::Move => Ok(DesktopMutationRequest::Move(MoveRequest {
|
||
source,
|
||
destination: self.destination.clone(),
|
||
force: false,
|
||
})),
|
||
MutationKind::Copy => Ok(DesktopMutationRequest::Copy(CopyRequest {
|
||
source,
|
||
destination: self.destination.clone(),
|
||
force: false,
|
||
})),
|
||
MutationKind::Delete if !self.confirmed => {
|
||
Err("Confirm permanent removal first.".to_owned())
|
||
}
|
||
MutationKind::Delete => Ok(DesktopMutationRequest::Remove(RemoveRequest {
|
||
entry: source,
|
||
recursive: self.source.is_directory(),
|
||
force: false,
|
||
})),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum RecipientWorkflowKind {
|
||
InitializeStore,
|
||
NewFolder,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
struct RecipientChoice {
|
||
fingerprint: String,
|
||
label: String,
|
||
selected: bool,
|
||
default: bool,
|
||
can_default: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
struct RecipientForm {
|
||
kind: RecipientWorkflowKind,
|
||
path: String,
|
||
recipients: Vec<RecipientChoice>,
|
||
default_key: String,
|
||
confirmed: bool,
|
||
running: bool,
|
||
error: Option<String>,
|
||
}
|
||
|
||
impl RecipientForm {
|
||
fn new(
|
||
kind: RecipientWorkflowKind,
|
||
storage: &DesktopStorage,
|
||
path: String,
|
||
) -> Result<Self, String> {
|
||
let default_key = storage.default_key();
|
||
let mut recipients = storage
|
||
.key_infos()
|
||
.map_err(|error| error.to_string())?
|
||
.into_iter()
|
||
.map(|key| {
|
||
let default = key.fingerprint().as_str() == default_key;
|
||
RecipientChoice {
|
||
label: format!(
|
||
"{} — {}",
|
||
key.key_id(),
|
||
key.user_ids()
|
||
.first()
|
||
.map_or("unknown identity", String::as_str)
|
||
),
|
||
fingerprint: key.fingerprint().to_string(),
|
||
selected: default,
|
||
default,
|
||
can_default: key.has_secret(),
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
if !recipients.iter().any(|choice| choice.selected)
|
||
&& let Some(first) = recipients.first_mut()
|
||
{
|
||
first.selected = true;
|
||
}
|
||
let default_key = recipients
|
||
.iter()
|
||
.find(|choice| choice.default && choice.can_default)
|
||
.or_else(|| recipients.iter().find(|choice| choice.can_default))
|
||
.map(|choice| choice.fingerprint.clone())
|
||
.ok_or_else(|| "No encryption key with secret key material is available.".to_owned())?;
|
||
Ok(Self {
|
||
kind,
|
||
path,
|
||
recipients,
|
||
default_key,
|
||
confirmed: false,
|
||
running: false,
|
||
error: None,
|
||
})
|
||
}
|
||
|
||
fn request(&self) -> Result<InitRequest, String> {
|
||
let path = self.path.trim();
|
||
if self.kind == RecipientWorkflowKind::NewFolder && path.is_empty() {
|
||
return Err("Enter a folder path.".to_owned());
|
||
}
|
||
let key_identities = self
|
||
.recipients
|
||
.iter()
|
||
.filter(|choice| choice.selected)
|
||
.map(|choice| choice.fingerprint.clone())
|
||
.collect::<Vec<_>>();
|
||
if key_identities.is_empty() {
|
||
return Err("Select at least one encryption recipient.".to_owned());
|
||
}
|
||
if self.kind == RecipientWorkflowKind::InitializeStore
|
||
&& !self.recipients.iter().any(|choice| {
|
||
choice.fingerprint == self.default_key && choice.selected && choice.can_default
|
||
})
|
||
{
|
||
return Err("Select a recipient with secret material as the default key.".to_owned());
|
||
}
|
||
if !self.confirmed {
|
||
return Err(
|
||
"Confirm the recipient-policy replacement and selective re-encryption.".to_owned(),
|
||
);
|
||
}
|
||
Ok(InitRequest {
|
||
path: (!path.is_empty()).then(|| path.to_owned()),
|
||
key_identities,
|
||
})
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
struct NewEntryForm {
|
||
path: String,
|
||
generate: bool,
|
||
length: String,
|
||
no_symbols: bool,
|
||
running: bool,
|
||
error: Option<String>,
|
||
}
|
||
|
||
impl NewEntryForm {
|
||
fn new(path: String) -> Self {
|
||
Self {
|
||
path,
|
||
generate: false,
|
||
length: GeneratorConfig::pass_defaults()
|
||
.default_length()
|
||
.to_string(),
|
||
no_symbols: false,
|
||
running: false,
|
||
error: None,
|
||
}
|
||
}
|
||
|
||
fn password(&self) -> Result<SecretBytes, String> {
|
||
if !self.generate {
|
||
return Ok(SecretBytes::new(Vec::new()));
|
||
}
|
||
let length = parse_generation_length(&self.length)?;
|
||
GeneratorConfig::pass_defaults()
|
||
.generate_secret(length, self.no_symbols)
|
||
.map_err(|error| error.to_string())
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
struct GenerateForm {
|
||
id: EntryFieldId,
|
||
length: String,
|
||
no_symbols: bool,
|
||
replacement: bool,
|
||
confirmed: bool,
|
||
error: Option<String>,
|
||
}
|
||
|
||
impl GenerateForm {
|
||
fn new(id: EntryFieldId, replacement: bool) -> Self {
|
||
Self {
|
||
id,
|
||
length: GeneratorConfig::pass_defaults()
|
||
.default_length()
|
||
.to_string(),
|
||
no_symbols: false,
|
||
replacement,
|
||
confirmed: false,
|
||
error: None,
|
||
}
|
||
}
|
||
|
||
fn generate(&self) -> Result<SecretBytes, String> {
|
||
if self.replacement && !self.confirmed {
|
||
return Err("Confirm replacement of the current field value.".to_owned());
|
||
}
|
||
GeneratorConfig::pass_defaults()
|
||
.generate_secret(parse_generation_length(&self.length)?, self.no_symbols)
|
||
.map_err(|error| error.to_string())
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
struct SettingsForm {
|
||
vault: String,
|
||
default_key: String,
|
||
authentication_timeout: String,
|
||
saving: bool,
|
||
error: Option<String>,
|
||
command_links_status: Option<String>,
|
||
}
|
||
|
||
impl SettingsForm {
|
||
fn new(storage: &DesktopStorage) -> Self {
|
||
Self {
|
||
vault: storage.vault().display().to_string(),
|
||
default_key: storage.default_key().to_owned(),
|
||
authentication_timeout: storage
|
||
.authentication_timeout()
|
||
.duration()
|
||
.as_secs()
|
||
.to_string(),
|
||
saving: false,
|
||
error: None,
|
||
command_links_status: None,
|
||
}
|
||
}
|
||
|
||
fn settings(
|
||
&self,
|
||
storage: &DesktopStorage,
|
||
) -> Result<ironstorage::config::ConfigSettings, String> {
|
||
let timeout = self
|
||
.authentication_timeout
|
||
.trim()
|
||
.parse::<u64>()
|
||
.map_err(|_| "Authentication timeout must be a whole number of seconds.".to_owned())?;
|
||
let mut settings = storage.settings();
|
||
settings.set_vault(PathBuf::from(self.vault.trim()));
|
||
settings.set_default_key(self.default_key.trim().to_owned());
|
||
settings.set_authentication_timeout(Duration::from_secs(timeout));
|
||
Ok(settings)
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
enum TreeState {
|
||
Loading,
|
||
Empty,
|
||
Ready,
|
||
Error(String),
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
enum PendingAction {
|
||
OpenVault(PathBuf),
|
||
OpenEntry(String),
|
||
Reload(String),
|
||
CloseWindow(window::Id),
|
||
ApplyRecipients(RecipientForm),
|
||
CreateEntry(NewEntryForm),
|
||
SearchContents(GrepRequest),
|
||
Mutate(MutationForm),
|
||
Git(DesktopGitRequest),
|
||
ImportKdbx(KdbxForm),
|
||
}
|
||
|
||
#[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())
|
||
.run()
|
||
}
|
||
|
||
fn window_settings() -> window::Settings {
|
||
window::Settings {
|
||
size: Size::new(960.0, 680.0),
|
||
min_size: Some(Size::new(480.0, 360.0)),
|
||
..window::Settings::default()
|
||
}
|
||
}
|
||
|
||
impl App {
|
||
fn new() -> (Self, Task<Message>) {
|
||
let app = Self {
|
||
authentication: AuthenticationView::Loading,
|
||
storage: None,
|
||
session: None,
|
||
key: None,
|
||
handle: None,
|
||
sensitive: SensitiveUiState::default(),
|
||
git_control: None,
|
||
git_progress: None,
|
||
authentication_generation: 0,
|
||
operation_generation: 0,
|
||
tree_generation: 0,
|
||
vault_generation: 0,
|
||
settings_generation: 0,
|
||
workflow_generation: 0,
|
||
otp_generation: 0,
|
||
otp_pending: false,
|
||
selection_after_refresh: None,
|
||
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,
|
||
switching_vault: false,
|
||
confirmation: None,
|
||
after_save: None,
|
||
generation_form: None,
|
||
conflict: false,
|
||
status: "Loading shared configuration…".to_owned(),
|
||
open_menu: None,
|
||
utility: None,
|
||
context_target: None,
|
||
palette: CommandPalette::default(),
|
||
#[cfg(target_os = "macos")]
|
||
native_menu: None,
|
||
};
|
||
(
|
||
app,
|
||
Task::perform(load_authentication(), |result| {
|
||
Message::StartupLoaded(Box::new(result))
|
||
}),
|
||
)
|
||
}
|
||
|
||
fn update(&mut self, message: Message) -> Task<Message> {
|
||
match message {
|
||
Message::Action(UiAction::CommandPalette) => return self.toggle_palette(),
|
||
Message::Action(action) => {
|
||
if self.palette.is_open() {
|
||
self.palette.close();
|
||
let restore = self.restore_focus();
|
||
let action = self.invoke_action(action);
|
||
return Task::batch([restore, 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::PaletteQueryChanged(query) => {
|
||
self.palette.update_query(query);
|
||
return iced::widget::operation::snap_to(
|
||
command_palette_scroll_id(),
|
||
scrollable::RelativeOffset::START,
|
||
);
|
||
}
|
||
Message::PaletteCancel => {
|
||
self.touch_user_activity();
|
||
if self.utility.is_some() {
|
||
return self.update(Message::DismissUtility);
|
||
}
|
||
if self.palette.is_open() {
|
||
self.palette.close();
|
||
return self.restore_focus();
|
||
}
|
||
}
|
||
Message::PaletteInvoke(action) => return self.invoke_palette_action(action),
|
||
Message::DismissUtility => {
|
||
if self.utility.as_ref().is_some_and(|utility| {
|
||
matches!(utility, UtilityView::Settings(form) if form.saving)
|
||
|| matches!(utility, UtilityView::Recipients(form) if form.running)
|
||
|| matches!(utility, UtilityView::NewEntry(form) if form.running)
|
||
|| matches!(utility, UtilityView::Search(form) if form.running)
|
||
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|
||
|| matches!(utility, UtilityView::Git(form) if form.running)
|
||
|| matches!(utility, UtilityView::Otp(form) if form.running)
|
||
|| matches!(utility, UtilityView::Kdbx(form) if form.running)
|
||
}) {
|
||
self.status = "Wait for the active workflow to finish…".to_owned();
|
||
} else {
|
||
if matches!(&self.utility, Some(UtilityView::Otp(_))) {
|
||
self.sensitive.otp_uri = None;
|
||
self.sensitive.otp_qr = None;
|
||
self.otp_generation = self.otp_generation.wrapping_add(1);
|
||
self.otp_pending = false;
|
||
}
|
||
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(),
|
||
};
|
||
}
|
||
Message::FolderPicked(result) => match result {
|
||
Ok(Some(path)) => return self.request_action(PendingAction::OpenVault(path)),
|
||
Ok(None) => {
|
||
self.status = "Open Folder cancelled; current vault unchanged.".to_owned();
|
||
}
|
||
Err(error) => {
|
||
self.status =
|
||
format!("Folder picker failed: {error}. Current vault unchanged.");
|
||
}
|
||
},
|
||
Message::SettingsVaultChanged(vault) => {
|
||
if let Some(UtilityView::Settings(form)) = &mut self.utility
|
||
&& !form.saving
|
||
{
|
||
form.vault = vault;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::SettingsDefaultKeyChanged(default_key) => {
|
||
if let Some(UtilityView::Settings(form)) = &mut self.utility
|
||
&& !form.saving
|
||
{
|
||
form.default_key = default_key;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::SettingsTimeoutChanged(timeout) => {
|
||
if let Some(UtilityView::Settings(form)) = &mut self.utility
|
||
&& !form.saving
|
||
{
|
||
form.authentication_timeout = timeout;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::PickSettingsVault => {
|
||
let initial = match &self.utility {
|
||
Some(UtilityView::Settings(form)) if !form.saving => {
|
||
Some(PathBuf::from(&form.vault))
|
||
}
|
||
_ => None,
|
||
};
|
||
if initial.is_some() {
|
||
return Task::perform(
|
||
folder_picker::pick_folder(initial),
|
||
Message::SettingsVaultPicked,
|
||
);
|
||
}
|
||
}
|
||
Message::SettingsVaultPicked(result) => {
|
||
if let Some(UtilityView::Settings(form)) = &mut self.utility
|
||
&& !form.saving
|
||
{
|
||
match result {
|
||
Ok(Some(path)) => {
|
||
form.vault = path.display().to_string();
|
||
form.error = None;
|
||
}
|
||
Ok(None) => {}
|
||
Err(error) => form.error = Some(format!("Folder picker failed: {error}")),
|
||
}
|
||
}
|
||
}
|
||
Message::InstallCommandLinks => {
|
||
if let Some(UtilityView::Settings(form)) = &mut self.utility
|
||
&& !form.saving
|
||
{
|
||
form.command_links_status =
|
||
Some(match launcher::install_packaged_launchers() {
|
||
Ok(targets) => format!(
|
||
"Installed {} and {}.",
|
||
targets[0].display(),
|
||
targets[1].display()
|
||
),
|
||
Err(error) => format!("Command links were not installed: {error}"),
|
||
});
|
||
self.status = form.command_links_status.clone().unwrap_or_default();
|
||
}
|
||
}
|
||
Message::SaveSettings => return self.begin_settings_save(),
|
||
Message::SettingsFinished { generation, result } => {
|
||
if generation != self.settings_generation {
|
||
if let Ok((_storage, session, _key)) = *result {
|
||
let _ignored = session.manual_lock();
|
||
}
|
||
return Task::none();
|
||
}
|
||
match *result {
|
||
Ok((storage, session, key)) => {
|
||
if let Some(current) = &self.session {
|
||
let _ignored = current.manual_lock();
|
||
}
|
||
self.authentication_generation =
|
||
self.authentication_generation.wrapping_add(1);
|
||
self.operation_generation = self.operation_generation.wrapping_add(1);
|
||
self.sensitive.clear();
|
||
self.storage = Some(storage);
|
||
self.session = Some(session);
|
||
self.key = Some(key);
|
||
self.handle = None;
|
||
self.authentication = AuthenticationView::Locked;
|
||
self.editor = None;
|
||
self.content_mode = ContentMode::Viewer;
|
||
self.navigation = NavigationTree::default();
|
||
self.tree_state = TreeState::Loading;
|
||
self.entry_path.clear();
|
||
self.confirmation = None;
|
||
self.after_save = None;
|
||
self.after_authentication = None;
|
||
self.generation_form = None;
|
||
self.conflict = false;
|
||
self.utility = None;
|
||
self.status = "Settings saved; protected content was locked.".to_owned();
|
||
return self.begin_tree_refresh();
|
||
}
|
||
Err(error) => {
|
||
if let Some(UtilityView::Settings(form)) = &mut self.utility {
|
||
form.saving = false;
|
||
form.error = Some(error.clone());
|
||
}
|
||
self.status = format!("Settings were not saved: {error}");
|
||
}
|
||
}
|
||
}
|
||
Message::VaultSwitched { generation, result } => {
|
||
if generation != self.vault_generation {
|
||
return Task::none();
|
||
}
|
||
self.switching_vault = false;
|
||
match *result {
|
||
Ok(storage) => {
|
||
let vault = storage.vault().display().to_string();
|
||
self.operation_generation = self.operation_generation.wrapping_add(1);
|
||
self.authentication_generation =
|
||
self.authentication_generation.wrapping_add(1);
|
||
self.sensitive.clear();
|
||
if let Some(session) = &self.session {
|
||
let _ignored = session.manual_lock();
|
||
}
|
||
self.storage = Some(storage);
|
||
self.handle = None;
|
||
self.authentication = AuthenticationView::Locked;
|
||
self.editor = None;
|
||
self.content_mode = ContentMode::Viewer;
|
||
self.navigation = NavigationTree::default();
|
||
self.tree_state = TreeState::Loading;
|
||
self.entry_path.clear();
|
||
self.confirmation = None;
|
||
self.after_save = None;
|
||
self.after_authentication = None;
|
||
self.generation_form = None;
|
||
self.conflict = false;
|
||
self.status = format!("Opened password store at {vault}.");
|
||
return self.begin_tree_refresh();
|
||
}
|
||
Err(error) => {
|
||
self.status =
|
||
format!("Open Folder failed: {error}. Current vault unchanged.");
|
||
}
|
||
}
|
||
}
|
||
Message::RecipientPathChanged(path) => {
|
||
if let Some(UtilityView::Recipients(form)) = &mut self.utility
|
||
&& !form.running
|
||
&& form.kind == RecipientWorkflowKind::NewFolder
|
||
{
|
||
form.path = path;
|
||
form.error = None;
|
||
form.confirmed = false;
|
||
}
|
||
}
|
||
Message::ToggleRecipient(index) => {
|
||
if let Some(UtilityView::Recipients(form)) = &mut self.utility
|
||
&& !form.running
|
||
&& let Some(recipient) = form.recipients.get_mut(index)
|
||
{
|
||
recipient.selected = !recipient.selected;
|
||
if form.kind == RecipientWorkflowKind::InitializeStore
|
||
&& !recipient.selected
|
||
&& recipient.fingerprint == form.default_key
|
||
{
|
||
form.default_key.clear();
|
||
}
|
||
form.error = None;
|
||
form.confirmed = false;
|
||
}
|
||
}
|
||
Message::SelectDefaultRecipient(index) => {
|
||
if let Some(UtilityView::Recipients(form)) = &mut self.utility
|
||
&& !form.running
|
||
&& form.kind == RecipientWorkflowKind::InitializeStore
|
||
&& let Some(recipient) = form.recipients.get_mut(index)
|
||
&& recipient.can_default
|
||
{
|
||
recipient.selected = true;
|
||
form.default_key.clone_from(&recipient.fingerprint);
|
||
form.error = None;
|
||
form.confirmed = false;
|
||
}
|
||
}
|
||
Message::ToggleRecipientConfirmation => {
|
||
if let Some(UtilityView::Recipients(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.confirmed = !form.confirmed;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::SubmitRecipient => {
|
||
let Some(UtilityView::Recipients(form)) = &self.utility else {
|
||
return Task::none();
|
||
};
|
||
if form.running {
|
||
return Task::none();
|
||
}
|
||
if self.editor.as_ref().is_some_and(EntryEditor::is_dirty) {
|
||
if let Some(UtilityView::Recipients(form)) = &mut self.utility {
|
||
form.error = Some("Save or discard the current draft first.".to_owned());
|
||
}
|
||
return Task::none();
|
||
}
|
||
if let Err(error) = form.request() {
|
||
if let Some(UtilityView::Recipients(form)) = &mut self.utility {
|
||
form.error = Some(error);
|
||
}
|
||
return Task::none();
|
||
}
|
||
let form = form.clone();
|
||
if self.handle.is_none() {
|
||
self.after_authentication = Some(PendingAction::ApplyRecipients(form));
|
||
return self.begin_authentication();
|
||
}
|
||
return self.begin_recipient_workflow(form);
|
||
}
|
||
Message::RecipientFinished { generation, result } => {
|
||
if generation != self.workflow_generation {
|
||
return Task::none();
|
||
}
|
||
match *result {
|
||
Ok(summary) => {
|
||
if let Some(session) = &self.session {
|
||
let _ignored = session.manual_lock();
|
||
}
|
||
self.authentication_generation =
|
||
self.authentication_generation.wrapping_add(1);
|
||
self.sensitive.clear();
|
||
self.storage = Some(summary.storage);
|
||
self.key = Some(summary.key);
|
||
self.handle = None;
|
||
self.authentication = AuthenticationView::Locked;
|
||
self.utility = None;
|
||
self.editor = None;
|
||
self.content_mode = ContentMode::Viewer;
|
||
self.conflict = false;
|
||
self.status = format!(
|
||
"Recipient policy applied at {} for {} recipient(s); {} entry/entries re-encrypted.",
|
||
summary.summary.directory,
|
||
summary.summary.recipients,
|
||
summary.summary.reencrypted
|
||
);
|
||
return self.begin_tree_refresh();
|
||
}
|
||
Err(error) => {
|
||
if let Some(UtilityView::Recipients(form)) = &mut self.utility {
|
||
form.running = false;
|
||
form.error = Some(error.clone());
|
||
}
|
||
self.status = format!("Recipient workflow failed: {error}");
|
||
}
|
||
}
|
||
}
|
||
Message::NewEntryPathChanged(path) => {
|
||
if let Some(UtilityView::NewEntry(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.path = path;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::ToggleNewEntryGeneration => {
|
||
if let Some(UtilityView::NewEntry(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.generate = !form.generate;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::NewEntryLengthChanged(length) => {
|
||
if let Some(UtilityView::NewEntry(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.length = length;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::ToggleNewEntrySymbols => {
|
||
if let Some(UtilityView::NewEntry(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.no_symbols = !form.no_symbols;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::SubmitNewEntry => {
|
||
let Some(UtilityView::NewEntry(form)) = &self.utility else {
|
||
return Task::none();
|
||
};
|
||
if form.running {
|
||
return Task::none();
|
||
}
|
||
if form.path.trim().is_empty() {
|
||
if let Some(UtilityView::NewEntry(form)) = &mut self.utility {
|
||
form.error = Some("Enter an entry path.".to_owned());
|
||
}
|
||
return Task::none();
|
||
}
|
||
if let Err(error) = form.password() {
|
||
if let Some(UtilityView::NewEntry(form)) = &mut self.utility {
|
||
form.error = Some(error);
|
||
}
|
||
return Task::none();
|
||
}
|
||
let form = form.clone();
|
||
if self.handle.is_none() {
|
||
self.after_authentication = Some(PendingAction::CreateEntry(form));
|
||
return self.begin_authentication();
|
||
}
|
||
return self.begin_create_entry(form);
|
||
}
|
||
Message::CreateFinished {
|
||
generation,
|
||
completion,
|
||
} => {
|
||
let Some((password, result)) = take_completion(&completion) else {
|
||
return Task::none();
|
||
};
|
||
if generation != self.workflow_generation {
|
||
return Task::none();
|
||
}
|
||
match result.and_then(|document| {
|
||
EntryEditor::new_entry(document, password).map_err(|error| error.to_string())
|
||
}) {
|
||
Ok(editor) => {
|
||
self.entry_path = editor.entry();
|
||
self.editor = Some(editor);
|
||
self.content_mode = ContentMode::Editor;
|
||
self.pane_focus = PaneFocus::Content;
|
||
self.utility = None;
|
||
self.conflict = false;
|
||
self.status =
|
||
"New entry draft ready; nothing is stored until Save.".to_owned();
|
||
}
|
||
Err(error) => {
|
||
if let Some(UtilityView::NewEntry(form)) = &mut self.utility {
|
||
form.running = false;
|
||
form.error = Some(error.clone());
|
||
}
|
||
self.status = format!("New entry failed: {error}. Store unchanged.");
|
||
}
|
||
}
|
||
}
|
||
Message::SearchQueryChanged(query) => {
|
||
if let Some(UtilityView::Search(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.query = query;
|
||
form.error = None;
|
||
form.results = None;
|
||
}
|
||
}
|
||
Message::AddSearchTerm => {
|
||
if let Some(UtilityView::Search(form)) = &mut self.utility
|
||
&& !form.running
|
||
&& form.mode == SearchMode::Names
|
||
&& !form.query.is_empty()
|
||
{
|
||
form.terms.push(std::mem::take(&mut form.query));
|
||
form.error = None;
|
||
form.results = None;
|
||
}
|
||
}
|
||
Message::RemoveSearchTerm(index) => {
|
||
if let Some(UtilityView::Search(form)) = &mut self.utility
|
||
&& !form.running
|
||
&& form.mode == SearchMode::Names
|
||
&& index < form.terms.len()
|
||
{
|
||
form.terms.remove(index);
|
||
form.error = None;
|
||
form.results = None;
|
||
}
|
||
}
|
||
Message::ToggleSearchCase => self.update_search_form(|form| {
|
||
form.ignore_case = !form.ignore_case;
|
||
}),
|
||
Message::ToggleSearchInvert => self.update_search_form(|form| {
|
||
form.invert_match = !form.invert_match;
|
||
}),
|
||
Message::ToggleSearchLineNumbers => self.update_search_form(|form| {
|
||
form.line_numbers = !form.line_numbers;
|
||
}),
|
||
Message::ToggleSearchFixedStrings => self.update_search_form(|form| {
|
||
form.fixed_strings = !form.fixed_strings;
|
||
}),
|
||
Message::SubmitSearch => {
|
||
let request = match &self.utility {
|
||
Some(UtilityView::Search(form)) if !form.running => form.request(),
|
||
_ => return Task::none(),
|
||
};
|
||
let request = match request {
|
||
Ok(request) => request,
|
||
Err(error) => {
|
||
if let Some(UtilityView::Search(form)) = &mut self.utility {
|
||
form.error = Some(error);
|
||
}
|
||
return Task::none();
|
||
}
|
||
};
|
||
if let SearchRequest::Contents(request) = &request
|
||
&& self.handle.is_none()
|
||
{
|
||
self.after_authentication =
|
||
Some(PendingAction::SearchContents(request.clone()));
|
||
return self.begin_authentication();
|
||
}
|
||
return self.begin_search(request);
|
||
}
|
||
Message::SearchFinished {
|
||
generation,
|
||
completion,
|
||
} => {
|
||
let Some(result) = take_completion(&completion) else {
|
||
return Task::none();
|
||
};
|
||
if generation != self.workflow_generation {
|
||
return Task::none();
|
||
}
|
||
match result {
|
||
Ok(results) => {
|
||
let count = match &results {
|
||
SearchResults::Names(results) => results.matches().len(),
|
||
SearchResults::Contents(results) => results.entries().len(),
|
||
};
|
||
if let Some(UtilityView::Search(form)) = &mut self.utility {
|
||
form.running = false;
|
||
form.error = None;
|
||
form.results = Some(results);
|
||
}
|
||
self.status = format!("Search completed with {count} matching object(s).");
|
||
}
|
||
Err(error) => {
|
||
if let Some(UtilityView::Search(form)) = &mut self.utility {
|
||
form.running = false;
|
||
form.error = Some(error.clone());
|
||
}
|
||
self.status = format!("Search failed: {error}");
|
||
}
|
||
}
|
||
}
|
||
Message::ActivateSearchResult(id) => {
|
||
self.utility = None;
|
||
self.pane_focus = if id.is_directory() {
|
||
PaneFocus::Sidebar
|
||
} else {
|
||
PaneFocus::Content
|
||
};
|
||
let _selected = self.navigation.select_id(&id);
|
||
if let TreeNodeId::Entry(path) = id {
|
||
return self.request_action(PendingAction::OpenEntry(path.to_string()));
|
||
}
|
||
}
|
||
Message::MutationDestinationChanged(destination) => {
|
||
if let Some(UtilityView::Mutation(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.destination = destination;
|
||
form.overwrite = false;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::SelectMutationDestination(destination) => {
|
||
if let Some(UtilityView::Mutation(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.destination = destination;
|
||
form.overwrite = false;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::ToggleMutationOverwrite => {
|
||
if let Some(UtilityView::Mutation(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.overwrite = !form.overwrite;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::ToggleMutationConfirmation => {
|
||
if let Some(UtilityView::Mutation(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.confirmed = !form.confirmed;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::SubmitMutation => {
|
||
let form = match &self.utility {
|
||
Some(UtilityView::Mutation(form)) if !form.running => form.clone(),
|
||
_ => return Task::none(),
|
||
};
|
||
if let Err(error) = form.request() {
|
||
if let Some(UtilityView::Mutation(form)) = &mut self.utility {
|
||
form.error = Some(error);
|
||
}
|
||
return Task::none();
|
||
}
|
||
return self.request_action(PendingAction::Mutate(form));
|
||
}
|
||
Message::MutationFinished { generation, result } => {
|
||
if generation != self.workflow_generation {
|
||
return Task::none();
|
||
}
|
||
match *result {
|
||
Ok(outcome) => {
|
||
self.selection_after_refresh =
|
||
outcome.selection().map(|selection| match selection {
|
||
MutationSelection::Entry(path) => TreeNodeId::Entry(path.clone()),
|
||
MutationSelection::Directory(path) => {
|
||
TreeNodeId::Directory(path.clone())
|
||
}
|
||
});
|
||
let verb = match outcome.action() {
|
||
MutationAction::Remove => "Removed",
|
||
MutationAction::Move => "Moved",
|
||
MutationAction::Copy => "Copied",
|
||
};
|
||
self.utility = None;
|
||
self.editor = None;
|
||
self.content_mode = ContentMode::Viewer;
|
||
self.entry_path = self
|
||
.selection_after_refresh
|
||
.as_ref()
|
||
.map_or_else(String::new, |id| id.path().display().to_string());
|
||
self.status = format!(
|
||
"{verb} {} encrypted entry/entries; refreshing the tree.",
|
||
outcome.entries().len()
|
||
);
|
||
return self.begin_tree_refresh();
|
||
}
|
||
Err(error) => {
|
||
if let Some(UtilityView::Mutation(form)) = &mut self.utility {
|
||
form.running = false;
|
||
form.error = Some(error.clone());
|
||
}
|
||
self.status = format!("Mutation failed: {error}. Store unchanged.");
|
||
}
|
||
}
|
||
}
|
||
Message::RunGit(request) => {
|
||
if !matches!(&self.utility, Some(UtilityView::Git(form)) if !form.running) {
|
||
return Task::none();
|
||
}
|
||
let pending = PendingAction::Git(request.clone());
|
||
return if request.changes_worktree() {
|
||
self.request_action(pending)
|
||
} else {
|
||
self.execute_action(pending)
|
||
};
|
||
}
|
||
Message::ChooseGitConflict(index, choice) => {
|
||
if let Some(UtilityView::Git(form)) = &mut self.utility
|
||
&& !form.running
|
||
&& let Some(selection) = form.conflicts.get_mut(index)
|
||
{
|
||
selection.choice = Some(choice);
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::ResolveGitConflicts => {
|
||
let resolutions = match &self.utility {
|
||
Some(UtilityView::Git(form)) if !form.running => form.resolutions(),
|
||
_ => return Task::none(),
|
||
};
|
||
match resolutions {
|
||
Ok(resolutions) => {
|
||
return self.request_action(PendingAction::Git(
|
||
DesktopGitRequest::Resolve(resolutions),
|
||
));
|
||
}
|
||
Err(error) => {
|
||
if let Some(UtilityView::Git(form)) = &mut self.utility {
|
||
form.error = Some(error.clone());
|
||
}
|
||
self.status = error;
|
||
}
|
||
}
|
||
}
|
||
Message::CancelGit => {
|
||
if let Some(control) = &self.git_control {
|
||
control.cancel();
|
||
self.status = "Cancelling Git operation…".to_owned();
|
||
}
|
||
}
|
||
Message::GitFinished {
|
||
generation,
|
||
completion,
|
||
} => {
|
||
let Some(result) = take_completion(&completion) else {
|
||
return Task::none();
|
||
};
|
||
if generation != self.workflow_generation {
|
||
return Task::none();
|
||
}
|
||
self.git_control = None;
|
||
self.git_progress = None;
|
||
match result {
|
||
Ok(result) => {
|
||
let (outcome, snapshot, tree) = result.into_parts();
|
||
if git_outcome_changes_worktree(&outcome) {
|
||
self.editor = None;
|
||
self.content_mode = ContentMode::Viewer;
|
||
self.conflict = false;
|
||
}
|
||
if let Some(tree) = tree {
|
||
self.navigation.replace(&tree);
|
||
self.tree_state =
|
||
tree_state_from_result(Ok(self.navigation.is_empty()));
|
||
}
|
||
if let Some(UtilityView::Git(form)) = &mut self.utility {
|
||
form.snapshot = Some(snapshot);
|
||
form.progress = None;
|
||
form.running = false;
|
||
form.error = None;
|
||
form.conflicts.clear();
|
||
}
|
||
self.status = git_outcome_message(&outcome);
|
||
}
|
||
Err(error) => {
|
||
let conflicts = error
|
||
.conflicts()
|
||
.iter()
|
||
.cloned()
|
||
.map(|conflict| GitConflictSelection {
|
||
conflict,
|
||
choice: None,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let message = git_failure_message(&error);
|
||
if let Some(UtilityView::Git(form)) = &mut self.utility {
|
||
form.progress = None;
|
||
form.running = false;
|
||
form.error = Some(message.clone());
|
||
form.conflicts = conflicts;
|
||
}
|
||
self.status = if error.kind() == DesktopErrorKind::Conflict {
|
||
format!("Git requires explicit conflict resolution: {message}")
|
||
} else {
|
||
format!("Git operation failed: {message}")
|
||
};
|
||
}
|
||
}
|
||
}
|
||
Message::OtpEntryChanged(entry) => {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.entry = entry;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::OtpUriChanged(uri) => {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.uri = uri;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::ToggleOtpReplace => {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.replace = !form.replace;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::ToggleOtpRemovalConfirmation => {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.remove_confirmed = !form.remove_confirmed;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::PickOtpQr => {
|
||
if matches!(&self.utility, Some(UtilityView::Otp(form)) if !form.running) {
|
||
return Task::perform(
|
||
async { Arc::new(Mutex::new(Some(folder_picker::pick_qr_image().await))) },
|
||
Message::OtpQrPicked,
|
||
);
|
||
}
|
||
}
|
||
Message::OtpQrPicked(completion) => match take_completion(&completion) {
|
||
None => return Task::none(),
|
||
Some(Ok(Some(image))) => return self.begin_otp_import(Some(image)),
|
||
Some(Ok(None)) => {
|
||
self.status = "OTP QR import cancelled; store unchanged.".to_owned()
|
||
}
|
||
Some(Err(error)) => {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.error = Some(error.clone());
|
||
}
|
||
self.status = format!("OTP QR import failed: {error}. Store unchanged.");
|
||
}
|
||
},
|
||
Message::SubmitOtpImport => return self.begin_otp_import(None),
|
||
Message::SubmitOtpRemoval => {
|
||
let Some(UtilityView::Otp(form)) = &self.utility else {
|
||
return Task::none();
|
||
};
|
||
if !form.remove_confirmed {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.error = Some("Confirm permanent OTP removal first.".to_owned());
|
||
}
|
||
return Task::none();
|
||
}
|
||
return self.begin_otp_removal();
|
||
}
|
||
Message::RunOtpCode(copy) => {
|
||
let hotp = self
|
||
.editor
|
||
.as_ref()
|
||
.and_then(|editor| editor.focused().and_then(|id| editor.document().field(id)))
|
||
.and_then(|field| field.metadata().otp())
|
||
.is_some_and(|otp| otp.kind() == OtpKind::Hotp);
|
||
if hotp {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.copy_after_code = copy;
|
||
form.hotp_confirmation = true;
|
||
form.error = None;
|
||
}
|
||
self.status =
|
||
"Confirm HOTP generation; storage will commit the advanced counter."
|
||
.to_owned();
|
||
} else {
|
||
return self.begin_otp_code(copy, false);
|
||
}
|
||
}
|
||
Message::RunOtpUri { qr, copy } => return self.begin_otp_uri(qr, copy),
|
||
Message::ConfirmHotp => {
|
||
let copy = match &mut self.utility {
|
||
Some(UtilityView::Otp(form)) if form.hotp_confirmation && !form.running => {
|
||
form.hotp_confirmation = false;
|
||
form.copy_after_code
|
||
}
|
||
_ => return Task::none(),
|
||
};
|
||
return self.begin_otp_code(copy, true);
|
||
}
|
||
Message::OtpFinished {
|
||
generation,
|
||
completion,
|
||
} => {
|
||
let Some(result) = take_completion(&completion) else {
|
||
return Task::none();
|
||
};
|
||
if generation != self.otp_generation {
|
||
return Task::none();
|
||
}
|
||
self.otp_pending = false;
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.running = false;
|
||
}
|
||
match result {
|
||
Ok(OtpTaskResult::Code {
|
||
entry,
|
||
copy,
|
||
observed_at,
|
||
outcome,
|
||
}) => {
|
||
let (code, validity, metadata, tree, document) = outcome.into_parts();
|
||
if let Some(tree) = tree {
|
||
self.navigation.replace(&tree);
|
||
self.tree_state =
|
||
tree_state_from_result(Ok(self.navigation.is_empty()));
|
||
}
|
||
if let Some(document) = document {
|
||
let mut editor = EntryEditor::new(document);
|
||
if let Some(id) = editor
|
||
.fields()
|
||
.iter()
|
||
.find(|field| field.metadata().kind() == EntryFieldKind::OtpUri)
|
||
.map(|field| field.id())
|
||
{
|
||
editor.select(id);
|
||
}
|
||
self.editor = Some(editor);
|
||
self.content_mode = ContentMode::Viewer;
|
||
}
|
||
let copy_value = copy.then(|| SecretBytes::new(code.expose().to_vec()));
|
||
self.sensitive.otp_uri = None;
|
||
self.sensitive.otp_qr = None;
|
||
self.sensitive.otp = Some(OtpDisplay {
|
||
entry,
|
||
code,
|
||
validity,
|
||
metadata,
|
||
observed_at,
|
||
});
|
||
self.status = validity.counter().map_or_else(
|
||
|| "TOTP code generated from storage metadata.".to_owned(),
|
||
|counter| format!("HOTP counter {counter} generated and committed."),
|
||
);
|
||
if let Some(value) = copy_value {
|
||
return self.begin_secret_copy(value);
|
||
}
|
||
}
|
||
Ok(OtpTaskResult::Uri {
|
||
entry,
|
||
copy,
|
||
outcome,
|
||
}) => {
|
||
let (payload, matrix) = outcome.into_parts();
|
||
if copy {
|
||
self.sensitive.otp_uri = None;
|
||
self.sensitive.otp_qr = None;
|
||
self.status = format!("Copying OTP URI for {entry}.");
|
||
return self.begin_secret_copy(payload);
|
||
}
|
||
self.sensitive.otp_uri = matrix
|
||
.is_none()
|
||
.then(|| SecretBytes::new(payload.expose().to_vec()));
|
||
self.sensitive.otp_qr = matrix;
|
||
self.status = format!("Presented OTP URI for {entry}.");
|
||
}
|
||
Ok(OtpTaskResult::Imported(outcome)) | Ok(OtpTaskResult::Removed(outcome)) => {
|
||
let (entry, document, tree) = outcome.into_parts();
|
||
self.navigation.replace(&tree);
|
||
self.tree_state = tree_state_from_result(Ok(self.navigation.is_empty()));
|
||
let mut editor = EntryEditor::new(document);
|
||
if let Some(id) = editor
|
||
.fields()
|
||
.iter()
|
||
.find(|field| field.metadata().kind() == EntryFieldKind::OtpUri)
|
||
.map(|field| field.id())
|
||
{
|
||
editor.select(id);
|
||
}
|
||
self.editor = Some(editor);
|
||
self.entry_path = entry;
|
||
self.content_mode = ContentMode::Viewer;
|
||
self.sensitive.otp = None;
|
||
self.sensitive.otp_uri = None;
|
||
self.sensitive.otp_qr = None;
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.uri.clear();
|
||
form.replace = false;
|
||
form.remove_confirmed = false;
|
||
form.error = None;
|
||
}
|
||
self.status = "OTP mutation committed and entry reloaded.".to_owned();
|
||
}
|
||
Err(error) => {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.error = Some(error.to_string());
|
||
}
|
||
self.status = format!("OTP operation failed: {error}. Store unchanged.");
|
||
}
|
||
}
|
||
}
|
||
Message::KdbxSourceChanged(source) => {
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.source = source;
|
||
form.error = None;
|
||
form.summary = None;
|
||
}
|
||
}
|
||
Message::KdbxKeyFileChanged(key_file) => {
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.key_file = key_file;
|
||
form.error = None;
|
||
form.summary = None;
|
||
}
|
||
}
|
||
Message::KdbxPasswordChanged(password) => {
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.password = password;
|
||
form.error = None;
|
||
form.summary = None;
|
||
}
|
||
}
|
||
Message::PickKdbxSource => {
|
||
return Task::perform(folder_picker::pick_kdbx_file(), Message::KdbxSourcePicked);
|
||
}
|
||
Message::KdbxSourcePicked(result) => match result {
|
||
Ok(Some(path)) => {
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||
form.source = path.display().to_string();
|
||
form.error = None;
|
||
form.summary = None;
|
||
}
|
||
}
|
||
Ok(None) => {}
|
||
Err(error) => {
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||
form.error = Some(error);
|
||
}
|
||
}
|
||
},
|
||
Message::PickKdbxKeyFile => {
|
||
return Task::perform(folder_picker::pick_key_file(), Message::KdbxKeyFilePicked);
|
||
}
|
||
Message::KdbxKeyFilePicked(result) => match result {
|
||
Ok(Some(path)) => {
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||
form.key_file = path.display().to_string();
|
||
form.error = None;
|
||
form.summary = None;
|
||
}
|
||
}
|
||
Ok(None) => {}
|
||
Err(error) => {
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||
form.error = Some(error);
|
||
}
|
||
}
|
||
},
|
||
Message::ToggleKdbxQuickAdd => {
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.quick_add = !form.quick_add;
|
||
form.summary = None;
|
||
}
|
||
}
|
||
Message::ToggleKdbxConfirmation => {
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility
|
||
&& !form.running
|
||
{
|
||
form.confirmed = !form.confirmed;
|
||
}
|
||
}
|
||
Message::SubmitKdbxImport => {
|
||
let Some(UtilityView::Kdbx(form)) = &self.utility else {
|
||
return Task::none();
|
||
};
|
||
if form.running {
|
||
return Task::none();
|
||
}
|
||
if self.handle.is_none() {
|
||
self.after_authentication = Some(PendingAction::ImportKdbx(form.clone()));
|
||
return self.begin_authentication();
|
||
}
|
||
return self.begin_kdbx_import(form.clone());
|
||
}
|
||
Message::KdbxFinished { generation, result } => {
|
||
if generation != self.workflow_generation {
|
||
return Task::none();
|
||
}
|
||
match *result {
|
||
Ok((outcome, tree)) => {
|
||
self.navigation.replace(&tree);
|
||
self.tree_state = tree_state_from_result(Ok(self.navigation.is_empty()));
|
||
let summary = format!(
|
||
"KDBX import: {} added, {} updated, {} unchanged, {} skipped.",
|
||
outcome.added(),
|
||
outcome.updated(),
|
||
outcome.unchanged(),
|
||
outcome.skipped()
|
||
);
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||
form.running = false;
|
||
form.error = None;
|
||
form.summary = Some(summary.clone());
|
||
form.password = Zeroizing::new(String::new());
|
||
form.confirmed = false;
|
||
}
|
||
self.status = summary;
|
||
}
|
||
Err(error) => {
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||
form.running = false;
|
||
form.error = Some(error.clone());
|
||
}
|
||
self.status = format!("KDBX import failed: {error}");
|
||
}
|
||
}
|
||
}
|
||
#[cfg(target_os = "macos")]
|
||
Message::PollNativeMenu => {
|
||
if self.native_menu.is_none() {
|
||
match NativeMenu::install(self.action_context()) {
|
||
Ok(menu) => self.native_menu = Some(menu),
|
||
Err(error) => {
|
||
self.status = format!("Native menu unavailable: {error}");
|
||
return Task::none();
|
||
}
|
||
}
|
||
}
|
||
let context = self.action_context();
|
||
if let Some(menu) = &self.native_menu {
|
||
menu.sync(context);
|
||
}
|
||
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);
|
||
if let Some(id) = self.selection_after_refresh.take() {
|
||
let _selected = self.navigation.select_id(&id);
|
||
}
|
||
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.context_target = None;
|
||
self.touch_user_activity();
|
||
self.pane_focus = PaneFocus::Sidebar;
|
||
let intent = self.navigation.activate(id);
|
||
return self.handle_navigation(intent);
|
||
}
|
||
Message::SidebarContext(id) => {
|
||
let _selected = self.navigation.select_id(&id);
|
||
self.context_target = Some(id);
|
||
self.pane_focus = PaneFocus::Sidebar;
|
||
}
|
||
Message::SidebarContextAction(id, action) => {
|
||
let _selected = self.navigation.select_id(&id);
|
||
self.context_target = None;
|
||
return self.invoke_action(action);
|
||
}
|
||
Message::SidebarNavigate(key) => {
|
||
self.touch_user_activity();
|
||
if self.palette.is_open() {
|
||
return match key {
|
||
NavigationKey::Previous => {
|
||
self.palette
|
||
.move_selection(false, self.palette.results().len());
|
||
self.scroll_palette_selection()
|
||
}
|
||
NavigationKey::Next => {
|
||
self.palette
|
||
.move_selection(true, self.palette.results().len());
|
||
self.scroll_palette_selection()
|
||
}
|
||
NavigationKey::Activate => self
|
||
.palette
|
||
.selected_action()
|
||
.map_or_else(Task::none, |action| self.invoke_palette_action(action)),
|
||
NavigationKey::First => {
|
||
self.palette.select_first();
|
||
self.scroll_palette_selection()
|
||
}
|
||
NavigationKey::Last => {
|
||
self.palette.select_last(self.palette.results().len());
|
||
self.scroll_palette_selection()
|
||
}
|
||
NavigationKey::Collapse | NavigationKey::Expand => Task::none(),
|
||
};
|
||
}
|
||
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::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) => {
|
||
let generate_totp = document_has_single_totp(&document);
|
||
self.sensitive.otp = None;
|
||
self.sensitive.otp_uri = None;
|
||
self.sensitive.otp_qr = None;
|
||
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}");
|
||
if generate_totp {
|
||
return self.begin_otp_code_for(entry, false, false);
|
||
}
|
||
}
|
||
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.select(id);
|
||
editor.replace_value(id, SecretBytes::new(value.as_bytes().to_vec()))
|
||
});
|
||
}
|
||
Message::FieldEdited(id, action) => {
|
||
self.pane_focus = PaneFocus::Content;
|
||
self.edit(|editor| editor.edit_multiline(id, action));
|
||
}
|
||
Message::AddFieldLine(id, line) => {
|
||
self.pane_focus = PaneFocus::Content;
|
||
self.edit(|editor| editor.add_value_line_after(id, line));
|
||
}
|
||
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) => {
|
||
self.pane_focus = PaneFocus::Content;
|
||
if let Some(editor) = self.editor.as_mut() {
|
||
editor.select(id);
|
||
}
|
||
}
|
||
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());
|
||
self.generation_form = Some(GenerateForm::new(id, has_value));
|
||
}
|
||
Message::GenerateLengthChanged(length) => {
|
||
if let Some(form) = &mut self.generation_form {
|
||
form.length = length;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::ToggleGenerateSymbols => {
|
||
if let Some(form) = &mut self.generation_form {
|
||
form.no_symbols = !form.no_symbols;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::ToggleGenerateConfirmation => {
|
||
if let Some(form) = &mut self.generation_form {
|
||
form.confirmed = !form.confirmed;
|
||
form.error = None;
|
||
}
|
||
}
|
||
Message::SubmitGenerate => {
|
||
let Some(form) = self.generation_form.clone() else {
|
||
return Task::none();
|
||
};
|
||
match form.generate() {
|
||
Ok(password) => {
|
||
self.generation_form = None;
|
||
self.edit(|editor| editor.replace_value(form.id, password));
|
||
}
|
||
Err(error) => {
|
||
if let Some(form) = &mut self.generation_form {
|
||
form.error = Some(error);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Message::CancelGenerate => self.generation_form = None,
|
||
Message::Copy(id) => {
|
||
if !authentication_allows_content(&self.authentication) {
|
||
return Task::none();
|
||
}
|
||
let Some(editor) = &self.editor else {
|
||
return Task::none();
|
||
};
|
||
if self.storage.is_none() {
|
||
return Task::none();
|
||
}
|
||
let value = match editor.copy_value(id) {
|
||
Ok(value) => value,
|
||
Err(error) => {
|
||
self.status = error.to_string();
|
||
return Task::none();
|
||
}
|
||
};
|
||
return self.begin_secret_copy(value);
|
||
}
|
||
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 => {
|
||
self.poll_git_progress();
|
||
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()),
|
||
}
|
||
}
|
||
if let Some(remaining) = self.sensitive.clipboard_remaining(Instant::now()) {
|
||
self.status = format!("Clipboard cleanup in {remaining}s.");
|
||
}
|
||
let unix_seconds = current_unix_seconds().ok();
|
||
let refresh_totp = unix_seconds.and_then(|now| {
|
||
self.sensitive.otp.as_mut().and_then(|display| {
|
||
display.observed_at = now;
|
||
(display.validity.counter().is_none()
|
||
&& display.remaining_at(now) == Some(0))
|
||
.then(|| display.entry.clone())
|
||
})
|
||
});
|
||
if let Some(entry) = refresh_totp
|
||
&& self
|
||
.editor
|
||
.as_ref()
|
||
.is_some_and(|editor| editor.entry() == entry)
|
||
&& !self.otp_pending
|
||
&& self.handle.is_some()
|
||
{
|
||
return self.begin_otp_code_for(entry, false, false);
|
||
}
|
||
}
|
||
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,
|
||
switching_vault: self.switching_vault,
|
||
modal_open: self.confirmation.is_some()
|
||
|| self.generation_form.is_some()
|
||
|| self.utility.is_some(),
|
||
focused_field: focused.is_some(),
|
||
focused_generatable: focused.is_some_and(|field| {
|
||
field.metadata().sensitivity() == EntrySensitivity::Sensitive
|
||
&& field.metadata().kind() != EntryFieldKind::OtpUri
|
||
}),
|
||
focused_otp: focused.is_some_and(|field| field.metadata().otp().is_some()),
|
||
entry_path: !self.entry_path.trim().is_empty(),
|
||
selected_object: self.navigation.selected().is_some(),
|
||
git_running: self.git_control.is_some(),
|
||
}
|
||
}
|
||
|
||
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 => {
|
||
if let Some(storage) = &self.storage {
|
||
self.utility = Some(UtilityView::Settings(SettingsForm::new(storage)));
|
||
return iced::widget::operation::focus(settings_vault_id());
|
||
}
|
||
}
|
||
UiAction::InitializeStore | UiAction::NewFolder => {
|
||
let Some(storage) = &self.storage else {
|
||
return Task::none();
|
||
};
|
||
let kind = if action == UiAction::InitializeStore {
|
||
RecipientWorkflowKind::InitializeStore
|
||
} else {
|
||
RecipientWorkflowKind::NewFolder
|
||
};
|
||
let path = if kind == RecipientWorkflowKind::NewFolder {
|
||
self.entry_path
|
||
.trim()
|
||
.rsplit_once('/')
|
||
.map_or_else(String::new, |(parent, _)| format!("{parent}/"))
|
||
} else {
|
||
String::new()
|
||
};
|
||
match RecipientForm::new(kind, storage, path) {
|
||
Ok(form) => self.utility = Some(UtilityView::Recipients(form)),
|
||
Err(error) => self.status = format!("Cannot load recipients: {error}"),
|
||
}
|
||
}
|
||
UiAction::NewEntry => {
|
||
self.utility = Some(UtilityView::NewEntry(NewEntryForm::new(
|
||
self.entry_path.trim().to_owned(),
|
||
)));
|
||
}
|
||
UiAction::Find => {
|
||
self.utility = Some(UtilityView::Search(SearchForm::new(SearchMode::Names)));
|
||
}
|
||
UiAction::SearchContents => {
|
||
self.utility = Some(UtilityView::Search(SearchForm::new(SearchMode::Contents)));
|
||
}
|
||
UiAction::Help => self.utility = Some(UtilityView::Help),
|
||
UiAction::CommandPalette => return self.toggle_palette(),
|
||
UiAction::OpenFolder => {
|
||
let initial = self
|
||
.storage
|
||
.as_ref()
|
||
.map(|storage| storage.vault().to_owned());
|
||
self.status = "Choose a password-store folder…".to_owned();
|
||
return Task::perform(folder_picker::pick_folder(initial), Message::FolderPicked);
|
||
}
|
||
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::GeneratePassword => {
|
||
if let Some(id) = self.editor.as_ref().and_then(EntryEditor::focused) {
|
||
return self.update(Message::RequestGenerate(id));
|
||
}
|
||
}
|
||
UiAction::MoveEntry | UiAction::CopyEntry | UiAction::DeleteEntry => {
|
||
let Some(source) = self.navigation.selected().cloned() else {
|
||
return Task::none();
|
||
};
|
||
let kind = match action {
|
||
UiAction::MoveEntry => MutationKind::Move,
|
||
UiAction::CopyEntry => MutationKind::Copy,
|
||
UiAction::DeleteEntry => MutationKind::Delete,
|
||
_ => unreachable!(),
|
||
};
|
||
self.utility = Some(UtilityView::Mutation(MutationForm::new(kind, source)));
|
||
}
|
||
UiAction::ImportOtp => {
|
||
self.sensitive.otp = None;
|
||
self.sensitive.otp_uri = None;
|
||
self.sensitive.otp_qr = None;
|
||
self.utility = Some(UtilityView::Otp(OtpForm::new(
|
||
self.entry_path.trim().to_owned(),
|
||
)));
|
||
}
|
||
UiAction::ImportKdbx => {
|
||
self.utility = Some(UtilityView::Kdbx(KdbxForm::default()));
|
||
}
|
||
UiAction::GenerateOtp
|
||
| UiAction::CopyOtp
|
||
| UiAction::ShowOtpUri
|
||
| UiAction::CopyOtpUri
|
||
| UiAction::ShowOtpQr
|
||
| UiAction::RemoveOtp => {
|
||
let Some(editor) = &self.editor else {
|
||
return Task::none();
|
||
};
|
||
let Some(otp_kind) = editor
|
||
.focused()
|
||
.and_then(|id| editor.document().field(id))
|
||
.and_then(|field| field.metadata().otp())
|
||
.map(|otp| otp.kind())
|
||
else {
|
||
return Task::none();
|
||
};
|
||
let entry = editor.entry();
|
||
if matches!(action, UiAction::GenerateOtp | UiAction::CopyOtp)
|
||
&& otp_kind == OtpKind::Totp
|
||
{
|
||
return self.begin_otp_code_for(entry, action == UiAction::CopyOtp, false);
|
||
}
|
||
self.utility = Some(UtilityView::Otp(OtpForm::new(entry)));
|
||
match action {
|
||
UiAction::GenerateOtp | UiAction::CopyOtp if otp_kind == OtpKind::Hotp => {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.copy_after_code = action == UiAction::CopyOtp;
|
||
form.hotp_confirmation = true;
|
||
}
|
||
self.status =
|
||
"Confirm HOTP generation; storage will commit the advanced counter."
|
||
.to_owned();
|
||
}
|
||
UiAction::GenerateOtp | UiAction::CopyOtp => unreachable!(),
|
||
UiAction::ShowOtpUri => return self.begin_otp_uri(false, false),
|
||
UiAction::CopyOtpUri => return self.begin_otp_uri(false, true),
|
||
UiAction::ShowOtpQr => return self.begin_otp_uri(true, false),
|
||
UiAction::RemoveOtp => {
|
||
self.status =
|
||
"Confirm OTP removal; the entry change will be committed.".to_owned();
|
||
}
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
UiAction::GitStatus | UiAction::GitPull | UiAction::GitPush | UiAction::GitSync => {
|
||
let request = match action {
|
||
UiAction::GitStatus => DesktopGitRequest::Refresh,
|
||
UiAction::GitPull => DesktopGitRequest::Pull,
|
||
UiAction::GitPush => DesktopGitRequest::Push,
|
||
UiAction::GitSync => DesktopGitRequest::Sync,
|
||
_ => unreachable!(),
|
||
};
|
||
self.utility = Some(UtilityView::Git(GitForm::default()));
|
||
let pending = PendingAction::Git(request.clone());
|
||
return if request.changes_worktree() {
|
||
self.request_action(pending)
|
||
} else {
|
||
self.execute_action(pending)
|
||
};
|
||
}
|
||
UiAction::Lock => return self.update(Message::Lock),
|
||
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste => {}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
fn toggle_palette(&mut self) -> Task<Message> {
|
||
if self.palette.is_open() {
|
||
self.palette.close();
|
||
return self.restore_focus();
|
||
}
|
||
let context = self.action_context();
|
||
if let Some(reason) = action::disabled_reason(UiAction::CommandPalette, context) {
|
||
self.status = reason.to_owned();
|
||
return Task::none();
|
||
}
|
||
self.open_menu = None;
|
||
self.utility = None;
|
||
self.palette.open();
|
||
iced::widget::operation::focus(command_palette_input_id())
|
||
}
|
||
|
||
fn invoke_palette_action(&mut self, selected: UiAction) -> Task<Message> {
|
||
if !self.palette.is_open() {
|
||
return Task::none();
|
||
}
|
||
let context = self.action_context();
|
||
if let Some(reason) = action::disabled_reason(selected, context) {
|
||
self.status = format!("{}: {reason}.", action::spec_for(selected).label);
|
||
return Task::none();
|
||
}
|
||
self.palette.close();
|
||
let restore = self.restore_focus();
|
||
let action = self.invoke_action(selected);
|
||
Task::batch([restore, action])
|
||
}
|
||
|
||
fn scroll_palette_selection(&self) -> Task<Message> {
|
||
let result_count = self.palette.results().len();
|
||
let y = if result_count <= 1 {
|
||
0.0
|
||
} else {
|
||
self.palette.selected() as f32 / (result_count - 1) as f32
|
||
};
|
||
iced::widget::operation::snap_to(
|
||
command_palette_scroll_id(),
|
||
scrollable::RelativeOffset { x: 0.0, y },
|
||
)
|
||
}
|
||
|
||
fn restore_focus(&self) -> Task<Message> {
|
||
match self.pane_focus {
|
||
PaneFocus::Sidebar => iced::widget::operation::focus_next(),
|
||
PaneFocus::Content => iced::widget::operation::focus(
|
||
self.editor
|
||
.as_ref()
|
||
.and_then(EntryEditor::focused)
|
||
.map_or_else(content_focus_id, editor_field_input_id),
|
||
),
|
||
}
|
||
}
|
||
|
||
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.git_control.is_some() {
|
||
self.status = "Cancel or wait for the active Git operation first.".to_owned();
|
||
return Task::none();
|
||
}
|
||
if self.switching_vault && matches!(action, PendingAction::CloseWindow(_)) {
|
||
self.status =
|
||
"Wait for vault validation to finish before closing IronStorage.".to_owned();
|
||
return Task::none();
|
||
}
|
||
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::OpenVault(path) => self.begin_vault_switch(path),
|
||
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)
|
||
}
|
||
PendingAction::ApplyRecipients(form) => self.begin_recipient_workflow(form),
|
||
PendingAction::CreateEntry(form) => self.begin_create_entry(form),
|
||
PendingAction::SearchContents(request) => {
|
||
self.begin_search(SearchRequest::Contents(request))
|
||
}
|
||
PendingAction::Mutate(form) if self.handle.is_none() => {
|
||
self.after_authentication = Some(PendingAction::Mutate(form));
|
||
self.begin_authentication()
|
||
}
|
||
PendingAction::Mutate(form) => self.begin_mutation(form),
|
||
PendingAction::Git(request)
|
||
if request.requires_authentication() && self.handle.is_none() =>
|
||
{
|
||
self.after_authentication = Some(PendingAction::Git(request));
|
||
self.begin_authentication()
|
||
}
|
||
PendingAction::Git(request) => self.begin_git(request),
|
||
PendingAction::ImportKdbx(form) if self.handle.is_none() => {
|
||
self.after_authentication = Some(PendingAction::ImportKdbx(form));
|
||
self.begin_authentication()
|
||
}
|
||
PendingAction::ImportKdbx(form) => self.begin_kdbx_import(form),
|
||
}
|
||
}
|
||
|
||
fn begin_kdbx_import(&mut self, form: KdbxForm) -> Task<Message> {
|
||
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
|
||
return Task::none();
|
||
};
|
||
let (request, password) = match form.request() {
|
||
Ok(request) => request,
|
||
Err(error) => {
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||
form.error = Some(error);
|
||
}
|
||
return Task::none();
|
||
}
|
||
};
|
||
self.workflow_generation = self.workflow_generation.wrapping_add(1);
|
||
let generation = self.workflow_generation;
|
||
if let Some(UtilityView::Kdbx(form)) = &mut self.utility {
|
||
form.running = true;
|
||
form.error = None;
|
||
form.summary = None;
|
||
}
|
||
self.status = "Importing KeePass database through crates/storage…".to_owned();
|
||
Task::perform(
|
||
async move {
|
||
Box::new(
|
||
storage
|
||
.import_kdbx_active(&handle, &request, password)
|
||
.map_err(|error| error.to_string()),
|
||
)
|
||
},
|
||
move |result| Message::KdbxFinished { generation, result },
|
||
)
|
||
}
|
||
|
||
fn update_search_form(&mut self, update: impl FnOnce(&mut SearchForm)) {
|
||
if let Some(UtilityView::Search(form)) = &mut self.utility
|
||
&& !form.running
|
||
&& form.mode == SearchMode::Contents
|
||
{
|
||
update(form);
|
||
form.error = None;
|
||
form.results = None;
|
||
}
|
||
}
|
||
|
||
fn begin_search(&mut self, request: SearchRequest) -> Task<Message> {
|
||
let Some(storage) = self.storage.clone() else {
|
||
return Task::none();
|
||
};
|
||
let handle = self.handle.clone();
|
||
if matches!(request, SearchRequest::Contents(_)) && handle.is_none() {
|
||
if let SearchRequest::Contents(request) = request {
|
||
self.after_authentication = Some(PendingAction::SearchContents(request));
|
||
}
|
||
return self.begin_authentication();
|
||
}
|
||
self.workflow_generation = self.workflow_generation.wrapping_add(1);
|
||
let generation = self.workflow_generation;
|
||
if let Some(UtilityView::Search(form)) = &mut self.utility {
|
||
form.running = true;
|
||
form.error = None;
|
||
form.results = None;
|
||
}
|
||
self.status = match request {
|
||
SearchRequest::Names(_) => "Searching entry names…".to_owned(),
|
||
SearchRequest::Contents(_) => "Searching decrypted entry contents…".to_owned(),
|
||
};
|
||
Task::perform(
|
||
async move {
|
||
Arc::new(Mutex::new(Some(match request {
|
||
SearchRequest::Names(request) => storage
|
||
.find(&request)
|
||
.map(SearchResults::Names)
|
||
.map_err(|error| error.to_string()),
|
||
SearchRequest::Contents(request) => storage
|
||
.grep_active(
|
||
&handle.expect("content search requires authentication"),
|
||
&request,
|
||
)
|
||
.map(SearchResults::Contents)
|
||
.map_err(|error| error.to_string()),
|
||
})))
|
||
},
|
||
move |completion| Message::SearchFinished {
|
||
generation,
|
||
completion,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn begin_mutation(&mut self, form: MutationForm) -> Task<Message> {
|
||
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
|
||
return Task::none();
|
||
};
|
||
let request = match form.request() {
|
||
Ok(request) => request,
|
||
Err(error) => {
|
||
if let Some(UtilityView::Mutation(form)) = &mut self.utility {
|
||
form.error = Some(error);
|
||
}
|
||
return Task::none();
|
||
}
|
||
};
|
||
let overwrite = if form.kind == MutationKind::Delete || form.overwrite {
|
||
OverwriteDecision::Allow
|
||
} else {
|
||
OverwriteDecision::Decline
|
||
};
|
||
self.workflow_generation = self.workflow_generation.wrapping_add(1);
|
||
let generation = self.workflow_generation;
|
||
if let Some(UtilityView::Mutation(current)) = &mut self.utility {
|
||
current.running = true;
|
||
current.error = None;
|
||
}
|
||
self.status = "Applying storage-owned tree mutation…".to_owned();
|
||
Task::perform(
|
||
async move {
|
||
Box::new(
|
||
storage
|
||
.mutate_active(&handle, &request, overwrite)
|
||
.map_err(|error| error.to_string()),
|
||
)
|
||
},
|
||
move |result| Message::MutationFinished { generation, result },
|
||
)
|
||
}
|
||
|
||
fn begin_git(&mut self, request: DesktopGitRequest) -> Task<Message> {
|
||
let Some(storage) = self.storage.clone() else {
|
||
return Task::none();
|
||
};
|
||
let handle = self.handle.clone();
|
||
if request.requires_authentication() && handle.is_none() {
|
||
self.after_authentication = Some(PendingAction::Git(request));
|
||
return self.begin_authentication();
|
||
}
|
||
self.workflow_generation = self.workflow_generation.wrapping_add(1);
|
||
let generation = self.workflow_generation;
|
||
let progress = Arc::new(Mutex::new(None));
|
||
let reported = Arc::clone(&progress);
|
||
let control = GitOperationControl::new(move |phase| {
|
||
if let Ok(mut current) = reported.lock() {
|
||
*current = Some(phase);
|
||
}
|
||
});
|
||
self.git_control = Some(control.clone());
|
||
self.git_progress = Some(progress);
|
||
if let Some(UtilityView::Git(form)) = &mut self.utility {
|
||
form.progress = Some(GitProgressPhase::Validating);
|
||
form.running = true;
|
||
form.error = None;
|
||
}
|
||
self.status = format!("Git {}…", git_request_name(&request));
|
||
Task::perform(
|
||
async move {
|
||
Arc::new(Mutex::new(Some(storage.git_operation(
|
||
handle.as_ref(),
|
||
&request,
|
||
&control,
|
||
))))
|
||
},
|
||
move |completion| Message::GitFinished {
|
||
generation,
|
||
completion,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn begin_otp_code(&mut self, copy: bool, confirm_hotp: bool) -> Task<Message> {
|
||
let entry = match &self.utility {
|
||
Some(UtilityView::Otp(form)) if !form.running => form.entry.trim().to_owned(),
|
||
_ => return Task::none(),
|
||
};
|
||
self.begin_otp_code_for(entry, copy, confirm_hotp)
|
||
}
|
||
|
||
fn begin_otp_code_for(
|
||
&mut self,
|
||
entry: String,
|
||
copy: bool,
|
||
confirm_hotp: bool,
|
||
) -> Task<Message> {
|
||
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
|
||
return Task::none();
|
||
};
|
||
let unix_seconds = match current_unix_seconds() {
|
||
Ok(unix_seconds) => unix_seconds,
|
||
Err(error) => {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.error = Some(error.clone());
|
||
}
|
||
self.status = error;
|
||
return Task::none();
|
||
}
|
||
};
|
||
self.otp_generation = self.otp_generation.wrapping_add(1);
|
||
let generation = self.otp_generation;
|
||
self.otp_pending = true;
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.running = true;
|
||
form.error = None;
|
||
}
|
||
self.status = format!("Generating OTP code for {entry}…");
|
||
Task::perform(
|
||
async move {
|
||
Arc::new(Mutex::new(Some(
|
||
storage
|
||
.otp_code_active(&handle, &entry, unix_seconds, confirm_hotp)
|
||
.map(|outcome| OtpTaskResult::Code {
|
||
entry,
|
||
copy,
|
||
observed_at: unix_seconds,
|
||
outcome,
|
||
}),
|
||
)))
|
||
},
|
||
move |completion| Message::OtpFinished {
|
||
generation,
|
||
completion,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn begin_otp_uri(&mut self, qr: bool, copy: bool) -> Task<Message> {
|
||
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
|
||
return Task::none();
|
||
};
|
||
let entry = match &self.utility {
|
||
Some(UtilityView::Otp(form)) if !form.running => form.entry.trim().to_owned(),
|
||
_ => return Task::none(),
|
||
};
|
||
self.otp_generation = self.otp_generation.wrapping_add(1);
|
||
let generation = self.otp_generation;
|
||
self.otp_pending = true;
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.running = true;
|
||
form.error = None;
|
||
}
|
||
Task::perform(
|
||
async move {
|
||
Arc::new(Mutex::new(Some(
|
||
storage
|
||
.otp_uri_active(&handle, &entry, qr)
|
||
.map(|outcome| OtpTaskResult::Uri {
|
||
entry,
|
||
copy,
|
||
outcome,
|
||
}),
|
||
)))
|
||
},
|
||
move |completion| Message::OtpFinished {
|
||
generation,
|
||
completion,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn begin_otp_import(&mut self, image: Option<SecretBytes>) -> Task<Message> {
|
||
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
|
||
return Task::none();
|
||
};
|
||
let (entry, uri, replace) = match &self.utility {
|
||
Some(UtilityView::Otp(form)) if !form.running => (
|
||
form.entry.trim().to_owned(),
|
||
SecretBytes::new(form.uri.as_bytes().to_vec()),
|
||
form.replace,
|
||
),
|
||
_ => return Task::none(),
|
||
};
|
||
if entry.is_empty() {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.error = Some("Enter an entry path.".to_owned());
|
||
}
|
||
return Task::none();
|
||
}
|
||
if image.is_none() && uri.expose().is_empty() {
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.error = Some("Enter an otpauth URI or choose a QR image.".to_owned());
|
||
}
|
||
return Task::none();
|
||
}
|
||
let replace = if replace {
|
||
OverwriteDecision::Allow
|
||
} else {
|
||
OverwriteDecision::Decline
|
||
};
|
||
self.otp_generation = self.otp_generation.wrapping_add(1);
|
||
let generation = self.otp_generation;
|
||
self.otp_pending = true;
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.running = true;
|
||
form.error = None;
|
||
}
|
||
self.status = format!("Importing OTP into {entry}…");
|
||
Task::perform(
|
||
async move {
|
||
Arc::new(Mutex::new(Some(
|
||
match image {
|
||
Some(image) => {
|
||
storage.import_otp_qr_active(&handle, &entry, image, replace)
|
||
}
|
||
None => storage.import_otp_active(&handle, &entry, uri, replace),
|
||
}
|
||
.map(OtpTaskResult::Imported),
|
||
)))
|
||
},
|
||
move |completion| Message::OtpFinished {
|
||
generation,
|
||
completion,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn begin_otp_removal(&mut self) -> Task<Message> {
|
||
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
|
||
return Task::none();
|
||
};
|
||
let entry = match &self.utility {
|
||
Some(UtilityView::Otp(form)) if !form.running => form.entry.trim().to_owned(),
|
||
_ => return Task::none(),
|
||
};
|
||
self.otp_generation = self.otp_generation.wrapping_add(1);
|
||
let generation = self.otp_generation;
|
||
self.otp_pending = true;
|
||
if let Some(UtilityView::Otp(form)) = &mut self.utility {
|
||
form.running = true;
|
||
form.error = None;
|
||
}
|
||
Task::perform(
|
||
async move {
|
||
Arc::new(Mutex::new(Some(
|
||
storage
|
||
.remove_otp_active(&handle, &entry)
|
||
.map(OtpTaskResult::Removed),
|
||
)))
|
||
},
|
||
move |completion| Message::OtpFinished {
|
||
generation,
|
||
completion,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn begin_secret_copy(&mut self, value: SecretBytes) -> Task<Message> {
|
||
let Some(storage) = &self.storage else {
|
||
return Task::none();
|
||
};
|
||
let timeout = storage.clipboard_timeout();
|
||
let (generation, cancel) = self.sensitive.begin_copy(timeout.duration());
|
||
self.status = format!(
|
||
"Copied; automatic clipboard cleanup in {}s.",
|
||
timeout.duration().as_secs()
|
||
);
|
||
Task::perform(copy_to_clipboard(value, timeout, cancel), move |result| {
|
||
Message::CopyFinished { generation, result }
|
||
})
|
||
}
|
||
|
||
fn begin_recipient_workflow(&mut self, form: RecipientForm) -> Task<Message> {
|
||
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
|
||
return Task::none();
|
||
};
|
||
let request = match form.request() {
|
||
Ok(request) => request,
|
||
Err(error) => {
|
||
if let Some(UtilityView::Recipients(form)) = &mut self.utility {
|
||
form.error = Some(error);
|
||
}
|
||
return Task::none();
|
||
}
|
||
};
|
||
let default_key = form.default_key.clone();
|
||
self.workflow_generation = self.workflow_generation.wrapping_add(1);
|
||
let generation = self.workflow_generation;
|
||
if let Some(UtilityView::Recipients(form)) = &mut self.utility {
|
||
form.running = true;
|
||
form.error = None;
|
||
}
|
||
self.status = "Applying storage-owned recipient policy…".to_owned();
|
||
Task::perform(
|
||
async move {
|
||
let result = (|| {
|
||
let key = storage
|
||
.key_infos()
|
||
.map_err(|error| error.to_string())?
|
||
.into_iter()
|
||
.find(|key| key.fingerprint().as_str() == default_key)
|
||
.ok_or_else(|| "the selected default GPG key is unavailable".to_owned())?;
|
||
let (storage, outcome) = storage
|
||
.apply_active_recipient_policy(&handle, &request, &default_key)
|
||
.map_err(|error| error.to_string())?;
|
||
Ok(RecipientSuccess {
|
||
storage,
|
||
key,
|
||
summary: RecipientSummary {
|
||
directory: if outcome.directory().as_path().as_os_str().is_empty() {
|
||
"store root".to_owned()
|
||
} else {
|
||
outcome.directory().as_path().display().to_string()
|
||
},
|
||
recipients: outcome.recipients().len(),
|
||
reencrypted: outcome.reencrypted_entries().len(),
|
||
},
|
||
})
|
||
})();
|
||
Box::new(result)
|
||
},
|
||
move |result| Message::RecipientFinished { generation, result },
|
||
)
|
||
}
|
||
|
||
fn begin_create_entry(&mut self, form: NewEntryForm) -> Task<Message> {
|
||
let (Some(storage), Some(handle)) = (self.storage.clone(), self.handle.clone()) else {
|
||
return Task::none();
|
||
};
|
||
let entry = form.path.trim().to_owned();
|
||
if entry.is_empty() {
|
||
if let Some(UtilityView::NewEntry(form)) = &mut self.utility {
|
||
form.error = Some("Enter an entry path.".to_owned());
|
||
}
|
||
return Task::none();
|
||
}
|
||
let password = match form.password() {
|
||
Ok(password) => password,
|
||
Err(error) => {
|
||
if let Some(UtilityView::NewEntry(form)) = &mut self.utility {
|
||
form.error = Some(error);
|
||
}
|
||
return Task::none();
|
||
}
|
||
};
|
||
self.workflow_generation = self.workflow_generation.wrapping_add(1);
|
||
let generation = self.workflow_generation;
|
||
if let Some(UtilityView::NewEntry(form)) = &mut self.utility {
|
||
form.running = true;
|
||
form.error = None;
|
||
}
|
||
self.status = format!("Preparing new entry draft for {entry}…");
|
||
Task::perform(
|
||
async move {
|
||
Arc::new(Mutex::new(Some((
|
||
password,
|
||
storage
|
||
.create_active_document(&handle, &entry)
|
||
.map_err(|error| error.to_string()),
|
||
))))
|
||
},
|
||
move |completion| Message::CreateFinished {
|
||
generation,
|
||
completion,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn begin_settings_save(&mut self) -> Task<Message> {
|
||
let Some(storage) = self.storage.clone() else {
|
||
return Task::none();
|
||
};
|
||
let settings = match &self.utility {
|
||
Some(UtilityView::Settings(form)) if !form.saving => match form.settings(&storage) {
|
||
Ok(settings) => settings,
|
||
Err(error) => {
|
||
if let Some(UtilityView::Settings(form)) = &mut self.utility {
|
||
form.error = Some(error.clone());
|
||
}
|
||
self.status = error;
|
||
return Task::none();
|
||
}
|
||
},
|
||
_ => return Task::none(),
|
||
};
|
||
self.settings_generation = self.settings_generation.wrapping_add(1);
|
||
let generation = self.settings_generation;
|
||
if let Some(UtilityView::Settings(form)) = &mut self.utility {
|
||
form.saving = true;
|
||
form.error = None;
|
||
}
|
||
self.status = "Validating and saving shared settings…".to_owned();
|
||
Task::perform(
|
||
async move {
|
||
Box::new(
|
||
storage
|
||
.update_settings_and_bootstrap(settings)
|
||
.map(|bootstrap| bootstrap.into_parts())
|
||
.map_err(|error| error.to_string()),
|
||
)
|
||
},
|
||
move |result| Message::SettingsFinished { generation, result },
|
||
)
|
||
}
|
||
|
||
fn begin_vault_switch(&mut self, path: PathBuf) -> Task<Message> {
|
||
let Some(storage) = self.storage.clone() else {
|
||
self.status = "Load a valid shared configuration before opening a folder.".to_owned();
|
||
return Task::none();
|
||
};
|
||
self.vault_generation = self.vault_generation.wrapping_add(1);
|
||
let generation = self.vault_generation;
|
||
self.switching_vault = true;
|
||
self.status = format!("Validating password store at {}…", path.display());
|
||
Task::perform(
|
||
async move {
|
||
Box::new(
|
||
storage
|
||
.switch_vault(&path)
|
||
.map_err(|error| error.to_string()),
|
||
)
|
||
},
|
||
move |result| Message::VaultSwitched { generation, result },
|
||
)
|
||
}
|
||
|
||
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 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 => {}
|
||
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 poll_git_progress(&mut self) {
|
||
let phase = self
|
||
.git_progress
|
||
.as_ref()
|
||
.and_then(|progress| progress.lock().ok().and_then(|phase| *phase));
|
||
let Some(phase) = phase else {
|
||
return;
|
||
};
|
||
if let Some(UtilityView::Git(form)) = &mut self.utility
|
||
&& form.progress != Some(phase)
|
||
{
|
||
form.progress = Some(phase);
|
||
self.status = format!("Git {}… (Cancel remains available)", git_phase_name(phase));
|
||
}
|
||
}
|
||
|
||
fn authentication_lost(&mut self, reason: String) {
|
||
if let Some(control) = self.git_control.take() {
|
||
control.cancel();
|
||
}
|
||
self.git_progress = None;
|
||
self.operation_generation = self.operation_generation.wrapping_add(1);
|
||
self.workflow_generation = self.workflow_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.generation_form = None;
|
||
self.conflict = false;
|
||
match &mut self.utility {
|
||
Some(UtilityView::Recipients(form)) => {
|
||
form.running = false;
|
||
form.error = Some(reason.clone());
|
||
}
|
||
Some(UtilityView::NewEntry(form)) => {
|
||
form.running = false;
|
||
form.error = Some(reason.clone());
|
||
}
|
||
Some(UtilityView::Search(form)) if form.mode == SearchMode::Contents => {
|
||
form.running = false;
|
||
form.results = None;
|
||
form.error = Some(reason.clone());
|
||
}
|
||
Some(UtilityView::Mutation(form)) => {
|
||
form.running = false;
|
||
form.error = Some(reason.clone());
|
||
}
|
||
Some(UtilityView::Git(form)) => {
|
||
form.progress = None;
|
||
form.running = false;
|
||
form.error = Some(reason.clone());
|
||
}
|
||
Some(UtilityView::Otp(form)) => {
|
||
form.running = false;
|
||
form.hotp_confirmation = false;
|
||
form.uri.clear();
|
||
form.error = Some(reason.clone());
|
||
}
|
||
Some(UtilityView::Kdbx(form)) => {
|
||
form.running = false;
|
||
form.password = Zeroizing::new(String::new());
|
||
form.confirmed = false;
|
||
form.error = Some(reason.clone());
|
||
}
|
||
_ => {}
|
||
}
|
||
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> {
|
||
if let Some(action) = &self.confirmation {
|
||
return confirmation_view(action);
|
||
}
|
||
if let Some(form) = &self.generation_form {
|
||
return generation_view(form);
|
||
}
|
||
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,
|
||
self.context_target.as_ref(),
|
||
),
|
||
PaneKind::Content => content_view(self),
|
||
})
|
||
})
|
||
.spacing(1)
|
||
.min_size(140)
|
||
.on_resize(8, Message::PaneResized);
|
||
|
||
let vault = self.storage.as_ref().map_or_else(
|
||
|| "No vault".to_owned(),
|
||
|storage| {
|
||
storage
|
||
.vault()
|
||
.file_name()
|
||
.and_then(|name| name.to_str())
|
||
.map_or_else(
|
||
|| storage.vault().display().to_string(),
|
||
|name| name.to_owned(),
|
||
)
|
||
},
|
||
);
|
||
let top_bar = row![
|
||
text(format!("IronStorage · {vault}"))
|
||
.size(14)
|
||
.width(Length::Fill),
|
||
action_icon(Icon::Search, UiAction::Find),
|
||
action_icon(Icon::Command, UiAction::CommandPalette),
|
||
action_icon(Icon::Folder, UiAction::OpenFolder),
|
||
action_icon(Icon::Settings, UiAction::Settings),
|
||
action_icon(Icon::Power, UiAction::Lock),
|
||
]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(2)
|
||
.padding([5, 8]);
|
||
let mut chrome = column![top_bar, platform_menu_bar(self)];
|
||
if self.palette.is_open() {
|
||
let shortcut = action::shortcut_label(UiAction::CommandPalette)
|
||
.expect("the command palette has a registered shortcut");
|
||
chrome = chrome
|
||
.push(
|
||
text_input(
|
||
&format!("Search commands ({shortcut})"),
|
||
self.palette.query(),
|
||
)
|
||
.id(command_palette_input_id())
|
||
.on_input(Message::PaletteQueryChanged)
|
||
.padding(8),
|
||
)
|
||
.push(command_palette_results(self));
|
||
}
|
||
chrome = chrome.push(panes).push(
|
||
row![
|
||
text(authentication).size(12),
|
||
text(&self.status).size(12).width(Length::Fill),
|
||
text("Tab: next pane").size(12),
|
||
]
|
||
.spacing(10)
|
||
.padding([4, 8]),
|
||
);
|
||
|
||
container(chrome.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 command_palette_input_id() -> iced::widget::Id {
|
||
iced::widget::Id::new("desktop-command-palette")
|
||
}
|
||
|
||
fn command_palette_scroll_id() -> iced::widget::Id {
|
||
iced::widget::Id::new("desktop-command-palette-results")
|
||
}
|
||
|
||
fn content_focus_id() -> iced::widget::Id {
|
||
iced::widget::Id::new("desktop-entry-path")
|
||
}
|
||
|
||
fn settings_vault_id() -> iced::widget::Id {
|
||
iced::widget::Id::new("desktop-settings-vault")
|
||
}
|
||
|
||
fn editor_field_input_id(id: EntryFieldId) -> iced::widget::Id {
|
||
format!("desktop-entry-field-{}", id.value()).into()
|
||
}
|
||
|
||
fn action_hint(action: UiAction) -> String {
|
||
let spec = action::spec_for(action);
|
||
action::shortcut_label(action).map_or_else(
|
||
|| spec.label.to_owned(),
|
||
|shortcut| format!("{} ({shortcut})", spec.label),
|
||
)
|
||
}
|
||
|
||
#[derive(Clone, Copy)]
|
||
enum Icon {
|
||
Add,
|
||
Check,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Close,
|
||
Command,
|
||
Copy,
|
||
Delete,
|
||
Down,
|
||
Edit,
|
||
Folder,
|
||
FolderAdd,
|
||
Generate,
|
||
Link,
|
||
Key,
|
||
Move,
|
||
Power,
|
||
Qr,
|
||
Refresh,
|
||
Search,
|
||
Settings,
|
||
Up,
|
||
}
|
||
|
||
struct IconCanvas {
|
||
icon: Icon,
|
||
color: Option<Color>,
|
||
}
|
||
|
||
impl<Message> canvas::Program<Message> for IconCanvas {
|
||
type State = ();
|
||
|
||
fn draw(
|
||
&self,
|
||
_state: &Self::State,
|
||
renderer: &Renderer,
|
||
theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<canvas::Geometry> {
|
||
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
||
let path = canvas::Path::new(|path| match self.icon {
|
||
Icon::Add => {
|
||
path.move_to(Point::new(8.0, 2.0));
|
||
path.line_to(Point::new(8.0, 14.0));
|
||
path.move_to(Point::new(2.0, 8.0));
|
||
path.line_to(Point::new(14.0, 8.0));
|
||
}
|
||
Icon::Check => {
|
||
path.move_to(Point::new(2.0, 8.5));
|
||
path.line_to(Point::new(6.0, 12.0));
|
||
path.line_to(Point::new(14.0, 3.5));
|
||
}
|
||
Icon::ChevronDown => {
|
||
path.move_to(Point::new(3.0, 5.5));
|
||
path.line_to(Point::new(8.0, 10.5));
|
||
path.line_to(Point::new(13.0, 5.5));
|
||
}
|
||
Icon::ChevronRight => {
|
||
path.move_to(Point::new(5.5, 3.0));
|
||
path.line_to(Point::new(10.5, 8.0));
|
||
path.line_to(Point::new(5.5, 13.0));
|
||
}
|
||
Icon::Close => {
|
||
path.move_to(Point::new(3.0, 3.0));
|
||
path.line_to(Point::new(13.0, 13.0));
|
||
path.move_to(Point::new(13.0, 3.0));
|
||
path.line_to(Point::new(3.0, 13.0));
|
||
}
|
||
Icon::Command => {
|
||
path.move_to(Point::new(2.0, 4.0));
|
||
path.line_to(Point::new(6.0, 8.0));
|
||
path.line_to(Point::new(2.0, 12.0));
|
||
path.move_to(Point::new(8.0, 12.0));
|
||
path.line_to(Point::new(14.0, 12.0));
|
||
}
|
||
Icon::Copy => {
|
||
path.rectangle(Point::new(2.0, 2.0), Size::new(9.0, 9.0));
|
||
path.rectangle(Point::new(5.0, 5.0), Size::new(9.0, 9.0));
|
||
}
|
||
Icon::Delete => {
|
||
path.move_to(Point::new(3.0, 4.0));
|
||
path.line_to(Point::new(13.0, 4.0));
|
||
path.move_to(Point::new(6.0, 2.0));
|
||
path.line_to(Point::new(10.0, 2.0));
|
||
path.move_to(Point::new(4.5, 4.0));
|
||
path.line_to(Point::new(5.5, 14.0));
|
||
path.line_to(Point::new(10.5, 14.0));
|
||
path.line_to(Point::new(11.5, 4.0));
|
||
path.move_to(Point::new(7.0, 6.0));
|
||
path.line_to(Point::new(7.0, 12.0));
|
||
path.move_to(Point::new(9.0, 6.0));
|
||
path.line_to(Point::new(9.0, 12.0));
|
||
}
|
||
Icon::Down => {
|
||
path.move_to(Point::new(8.0, 2.0));
|
||
path.line_to(Point::new(8.0, 12.0));
|
||
path.move_to(Point::new(3.5, 8.0));
|
||
path.line_to(Point::new(8.0, 12.5));
|
||
path.line_to(Point::new(12.5, 8.0));
|
||
}
|
||
Icon::Edit => {
|
||
path.move_to(Point::new(3.0, 12.5));
|
||
path.line_to(Point::new(4.0, 8.5));
|
||
path.line_to(Point::new(11.0, 1.5));
|
||
path.line_to(Point::new(14.0, 4.5));
|
||
path.line_to(Point::new(7.0, 11.5));
|
||
path.line_to(Point::new(3.0, 12.5));
|
||
path.move_to(Point::new(3.0, 14.0));
|
||
path.line_to(Point::new(13.0, 14.0));
|
||
}
|
||
Icon::Folder | Icon::FolderAdd => {
|
||
path.move_to(Point::new(1.5, 4.0));
|
||
path.line_to(Point::new(6.0, 4.0));
|
||
path.line_to(Point::new(7.5, 6.0));
|
||
path.line_to(Point::new(14.5, 6.0));
|
||
path.line_to(Point::new(13.0, 13.0));
|
||
path.line_to(Point::new(2.5, 13.0));
|
||
path.line_to(Point::new(1.5, 4.0));
|
||
if matches!(self.icon, Icon::FolderAdd) {
|
||
path.move_to(Point::new(9.0, 9.5));
|
||
path.line_to(Point::new(13.0, 9.5));
|
||
path.move_to(Point::new(11.0, 7.5));
|
||
path.line_to(Point::new(11.0, 11.5));
|
||
}
|
||
}
|
||
Icon::Generate => {
|
||
path.rounded_rectangle(Point::new(2.0, 2.0), Size::new(12.0, 12.0), 2.0.into());
|
||
for point in [
|
||
(5.0, 5.0),
|
||
(11.0, 5.0),
|
||
(8.0, 8.0),
|
||
(5.0, 11.0),
|
||
(11.0, 11.0),
|
||
] {
|
||
path.circle(Point::new(point.0, point.1), 0.5);
|
||
}
|
||
}
|
||
Icon::Link => {
|
||
path.rounded_rectangle(Point::new(1.5, 5.0), Size::new(8.5, 6.0), 3.0.into());
|
||
path.rounded_rectangle(Point::new(6.0, 5.0), Size::new(8.5, 6.0), 3.0.into());
|
||
}
|
||
Icon::Key => {
|
||
path.circle(Point::new(5.0, 5.0), 3.5);
|
||
path.move_to(Point::new(7.5, 7.5));
|
||
path.line_to(Point::new(14.0, 14.0));
|
||
path.move_to(Point::new(10.5, 10.5));
|
||
path.line_to(Point::new(12.5, 8.5));
|
||
path.move_to(Point::new(12.5, 12.5));
|
||
path.line_to(Point::new(14.0, 11.0));
|
||
}
|
||
Icon::Move => {
|
||
path.move_to(Point::new(2.0, 13.5));
|
||
path.line_to(Point::new(13.5, 2.0));
|
||
path.move_to(Point::new(7.0, 2.0));
|
||
path.line_to(Point::new(13.5, 2.0));
|
||
path.line_to(Point::new(13.5, 8.5));
|
||
}
|
||
Icon::Power => {
|
||
path.circle(Point::new(8.0, 8.5), 5.5);
|
||
path.move_to(Point::new(8.0, 1.0));
|
||
path.line_to(Point::new(8.0, 8.0));
|
||
}
|
||
Icon::Qr => {
|
||
for point in [(2.0, 2.0), (9.5, 2.0), (2.0, 9.5)] {
|
||
path.rectangle(Point::new(point.0, point.1), Size::new(4.5, 4.5));
|
||
}
|
||
path.rectangle(Point::new(10.0, 10.0), Size::new(1.5, 1.5));
|
||
path.rectangle(Point::new(13.0, 10.0), Size::new(1.5, 4.5));
|
||
path.rectangle(Point::new(10.0, 13.0), Size::new(1.5, 1.5));
|
||
}
|
||
Icon::Refresh => {
|
||
path.move_to(Point::new(13.5, 6.0));
|
||
path.line_to(Point::new(13.5, 2.5));
|
||
path.line_to(Point::new(10.0, 2.5));
|
||
path.move_to(Point::new(13.0, 3.0));
|
||
path.bezier_curve_to(
|
||
Point::new(9.0, 0.0),
|
||
Point::new(3.0, 2.0),
|
||
Point::new(2.5, 7.0),
|
||
);
|
||
path.bezier_curve_to(
|
||
Point::new(2.0, 12.0),
|
||
Point::new(8.0, 16.0),
|
||
Point::new(12.5, 12.0),
|
||
);
|
||
}
|
||
Icon::Search => {
|
||
path.circle(Point::new(6.5, 6.5), 4.5);
|
||
path.move_to(Point::new(10.0, 10.0));
|
||
path.line_to(Point::new(14.0, 14.0));
|
||
}
|
||
Icon::Settings => {
|
||
path.circle(Point::new(8.0, 8.0), 3.0);
|
||
path.circle(Point::new(8.0, 8.0), 5.5);
|
||
for (from, to) in [
|
||
((8.0, 0.5), (8.0, 2.5)),
|
||
((8.0, 13.5), (8.0, 15.5)),
|
||
((0.5, 8.0), (2.5, 8.0)),
|
||
((13.5, 8.0), (15.5, 8.0)),
|
||
] {
|
||
path.move_to(Point::new(from.0, from.1));
|
||
path.line_to(Point::new(to.0, to.1));
|
||
}
|
||
}
|
||
Icon::Up => {
|
||
path.move_to(Point::new(8.0, 14.0));
|
||
path.line_to(Point::new(8.0, 4.0));
|
||
path.move_to(Point::new(3.5, 8.0));
|
||
path.line_to(Point::new(8.0, 3.5));
|
||
path.line_to(Point::new(12.5, 8.0));
|
||
}
|
||
});
|
||
frame.stroke(
|
||
&path,
|
||
canvas::Stroke::default()
|
||
.with_color(
|
||
self.color
|
||
.unwrap_or(theme.extended_palette().background.base.text),
|
||
)
|
||
.with_width(1.5)
|
||
.with_line_cap(canvas::LineCap::Round)
|
||
.with_line_join(canvas::LineJoin::Round),
|
||
);
|
||
vec![frame.into_geometry()]
|
||
}
|
||
}
|
||
|
||
fn icon_view(icon: Icon) -> Element<'static, Message> {
|
||
colored_icon_view(icon, None)
|
||
}
|
||
|
||
fn colored_icon_view(icon: Icon, color: Option<Color>) -> Element<'static, Message> {
|
||
canvas(IconCanvas { icon, color })
|
||
.width(Length::Fixed(16.0))
|
||
.height(Length::Fixed(16.0))
|
||
.into()
|
||
}
|
||
|
||
fn icon_control(icon: Icon, hint: String, message: Message) -> Element<'static, Message> {
|
||
tooltip(
|
||
button(icon_view(icon))
|
||
.padding(6)
|
||
.style(button::background)
|
||
.on_press(message),
|
||
container(text(hint).size(12))
|
||
.padding([5, 8])
|
||
.style(container::rounded_box),
|
||
tooltip::Position::Bottom,
|
||
)
|
||
.gap(4)
|
||
.delay(Duration::from_millis(350))
|
||
.into()
|
||
}
|
||
|
||
const ENTRY_AREA_BACKGROUND: Color = Color::from_rgb8(18, 18, 20);
|
||
const ENTRY_FIELD_BACKGROUND: Color = Color::from_rgb8(24, 24, 27);
|
||
const ENTRY_FIELD_ACTIVE_BACKGROUND: Color = Color::from_rgb8(47, 47, 52);
|
||
const ENTRY_INPUT_BACKGROUND: Color = Color::from_rgb8(12, 12, 14);
|
||
const ENTRY_TEXT: Color = Color::from_rgb8(235, 235, 238);
|
||
const ENTRY_MUTED_TEXT: Color = Color::from_rgb8(165, 165, 172);
|
||
const ENTRY_BORDER: Color = Color::from_rgb8(72, 72, 78);
|
||
const ENTRY_FOCUSED_BORDER: Color = Color::from_rgb8(145, 145, 152);
|
||
const ENTRY_LABEL_WIDTH: f32 = 128.0;
|
||
|
||
fn entry_icon_control(icon: Icon, hint: String, message: Message) -> Element<'static, Message> {
|
||
tooltip(
|
||
button(colored_icon_view(icon, Some(ENTRY_TEXT)))
|
||
.padding(6)
|
||
.style(entry_icon_button)
|
||
.on_press(message),
|
||
container(text(hint).size(12))
|
||
.padding([5, 8])
|
||
.style(container::dark),
|
||
tooltip::Position::Bottom,
|
||
)
|
||
.gap(4)
|
||
.delay(Duration::from_millis(350))
|
||
.into()
|
||
}
|
||
|
||
fn utility_icon_control(
|
||
icon: Icon,
|
||
hint: &'static str,
|
||
message: Message,
|
||
enabled: bool,
|
||
) -> Element<'static, Message> {
|
||
let control = button(colored_icon_view(icon, Some(ENTRY_TEXT)))
|
||
.padding(6)
|
||
.style(entry_icon_button);
|
||
tooltip(
|
||
if enabled {
|
||
control.on_press(message)
|
||
} else {
|
||
control
|
||
},
|
||
container(text(hint).size(12))
|
||
.padding([5, 8])
|
||
.style(container::dark),
|
||
tooltip::Position::Bottom,
|
||
)
|
||
.gap(4)
|
||
.delay(Duration::from_millis(350))
|
||
.into()
|
||
}
|
||
|
||
fn utility_button<'a>(
|
||
content: impl Into<Element<'a, Message>>,
|
||
) -> iced::widget::Button<'a, Message> {
|
||
button(content).padding([6, 10]).style(entry_icon_button)
|
||
}
|
||
|
||
fn entry_icon_button(_theme: &Theme, status: button::Status) -> button::Style {
|
||
button::Style {
|
||
background: matches!(status, button::Status::Hovered | button::Status::Pressed)
|
||
.then_some(Background::Color(ENTRY_FIELD_ACTIVE_BACKGROUND)),
|
||
text_color: ENTRY_TEXT,
|
||
..button::Style::default()
|
||
}
|
||
}
|
||
|
||
fn entry_area_style(_theme: &Theme) -> container::Style {
|
||
container::Style::default()
|
||
.background(ENTRY_AREA_BACKGROUND)
|
||
.color(ENTRY_TEXT)
|
||
}
|
||
|
||
fn entry_field_style(_theme: &Theme, selected: bool) -> container::Style {
|
||
container::Style::default()
|
||
.background(if selected {
|
||
ENTRY_FIELD_ACTIVE_BACKGROUND
|
||
} else {
|
||
ENTRY_FIELD_BACKGROUND
|
||
})
|
||
.color(ENTRY_TEXT)
|
||
}
|
||
|
||
fn entry_value_style(_theme: &Theme) -> container::Style {
|
||
container::Style::default()
|
||
.background(ENTRY_INPUT_BACKGROUND)
|
||
.color(ENTRY_TEXT)
|
||
}
|
||
|
||
fn entry_input_style(_theme: &Theme, status: text_input::Status) -> text_input::Style {
|
||
text_input::Style {
|
||
background: Background::Color(ENTRY_INPUT_BACKGROUND),
|
||
border: Border {
|
||
radius: 3.0.into(),
|
||
width: 1.0,
|
||
color: if matches!(status, text_input::Status::Focused { .. }) {
|
||
ENTRY_FOCUSED_BORDER
|
||
} else {
|
||
ENTRY_BORDER
|
||
},
|
||
},
|
||
icon: ENTRY_MUTED_TEXT,
|
||
placeholder: ENTRY_MUTED_TEXT,
|
||
value: ENTRY_TEXT,
|
||
selection: ENTRY_FIELD_ACTIVE_BACKGROUND,
|
||
}
|
||
}
|
||
|
||
fn entry_editor_style(_theme: &Theme, status: text_editor::Status) -> text_editor::Style {
|
||
text_editor::Style {
|
||
background: Background::Color(ENTRY_INPUT_BACKGROUND),
|
||
border: Border {
|
||
radius: 3.0.into(),
|
||
width: 1.0,
|
||
color: if matches!(status, text_editor::Status::Focused { .. }) {
|
||
ENTRY_FOCUSED_BORDER
|
||
} else {
|
||
ENTRY_BORDER
|
||
},
|
||
},
|
||
placeholder: ENTRY_MUTED_TEXT,
|
||
value: ENTRY_TEXT,
|
||
selection: ENTRY_FIELD_ACTIVE_BACKGROUND,
|
||
}
|
||
}
|
||
|
||
fn action_icon(icon: Icon, action: UiAction) -> Element<'static, Message> {
|
||
icon_control(icon, action_hint(action), Message::Action(action))
|
||
}
|
||
|
||
fn selected_button(theme: &Theme, status: button::Status) -> button::Style {
|
||
let palette = theme.extended_palette();
|
||
let mut style = button::text(theme, status);
|
||
style.background = Some(Background::Color(palette.primary.weak.color));
|
||
style.text_color = palette.primary.weak.text;
|
||
style
|
||
}
|
||
|
||
fn command_palette_results(app: &App) -> Element<'_, Message> {
|
||
let context = app.action_context();
|
||
let results = app.palette.results();
|
||
if results.is_empty() {
|
||
return container(text("No matching commands"))
|
||
.padding([8, 12])
|
||
.width(Length::Fill)
|
||
.into();
|
||
}
|
||
|
||
let mut rows = column![].spacing(2).padding([4, 8]);
|
||
for (index, action) in results.into_iter().enumerate() {
|
||
let spec = action::spec_for(action);
|
||
let shortcut = action::shortcut_label(action).unwrap_or_default();
|
||
let reason = action::disabled_reason(action, context);
|
||
let content = row![
|
||
text(spec.label).width(Length::Fill),
|
||
text(shortcut).size(13),
|
||
]
|
||
.spacing(12);
|
||
let item = button(content)
|
||
.width(Length::Fill)
|
||
.style(if index == app.palette.selected() {
|
||
selected_button
|
||
} else {
|
||
button::text
|
||
});
|
||
rows = rows.push(if reason.is_none() {
|
||
item.on_press(Message::PaletteInvoke(action))
|
||
} else {
|
||
item
|
||
});
|
||
}
|
||
container(
|
||
scrollable(rows)
|
||
.id(command_palette_scroll_id())
|
||
.height(Length::Fixed(240.0)),
|
||
)
|
||
.width(Length::Fill)
|
||
.into()
|
||
}
|
||
|
||
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_title(utility: &UtilityView) -> &'static str {
|
||
match utility {
|
||
UtilityView::About => "About IronStorage",
|
||
UtilityView::Settings(_) => "Settings",
|
||
UtilityView::Recipients(form) => match form.kind {
|
||
RecipientWorkflowKind::InitializeStore => "Initialize Password Store",
|
||
RecipientWorkflowKind::NewFolder => "Create Password Store Folder",
|
||
},
|
||
UtilityView::NewEntry(_) => "New Entry",
|
||
UtilityView::Search(form) => match form.mode {
|
||
SearchMode::Names => "Find Entries and Folders",
|
||
SearchMode::Contents => "Search Decrypted Contents",
|
||
},
|
||
UtilityView::Mutation(form) => match form.kind {
|
||
MutationKind::Move => "Move or Rename",
|
||
MutationKind::Copy => "Copy Entry or Folder",
|
||
MutationKind::Delete => "Delete Entry or Folder",
|
||
},
|
||
UtilityView::Git(_) => "Git Synchronization",
|
||
UtilityView::Otp(_) => "One-Time Password",
|
||
UtilityView::Kdbx(_) => "Import KeePass Database",
|
||
UtilityView::Help => "IronStorage Help",
|
||
}
|
||
}
|
||
|
||
fn utility_icon(utility: &UtilityView) -> Icon {
|
||
match utility {
|
||
UtilityView::About | UtilityView::Help => Icon::Key,
|
||
UtilityView::Settings(_) => Icon::Settings,
|
||
UtilityView::Recipients(_) => Icon::Key,
|
||
UtilityView::NewEntry(_) => Icon::Add,
|
||
UtilityView::Search(_) => Icon::Search,
|
||
UtilityView::Mutation(form) => match form.kind {
|
||
MutationKind::Move => Icon::Move,
|
||
MutationKind::Copy => Icon::Copy,
|
||
MutationKind::Delete => Icon::Delete,
|
||
},
|
||
UtilityView::Git(_) => Icon::Refresh,
|
||
UtilityView::Otp(_) => Icon::Generate,
|
||
UtilityView::Kdbx(_) => Icon::Down,
|
||
}
|
||
}
|
||
|
||
fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Message> {
|
||
let mut content = column![].spacing(10);
|
||
match utility {
|
||
UtilityView::About => {
|
||
content = content
|
||
.push(text(format!("Version {}", env!("CARGO_PKG_VERSION"))))
|
||
.push(text("A native, pass-compatible password-store client."))
|
||
.push(text("Compatible with pass and pass-otp."))
|
||
.push(text("Copyright IronStorage contributors."))
|
||
.push(text(
|
||
"Project license: pending selection. Third-party licenses are documented in DEPENDENCIES.md.",
|
||
));
|
||
}
|
||
UtilityView::Settings(form) => {
|
||
let general = column![
|
||
text("General").size(18),
|
||
text("Password-store folder").size(13),
|
||
row![
|
||
text_input("Vault path", &form.vault)
|
||
.id(settings_vault_id())
|
||
.on_input(Message::SettingsVaultChanged)
|
||
.on_submit(Message::SaveSettings)
|
||
.style(entry_input_style),
|
||
utility_icon_control(
|
||
Icon::Folder,
|
||
"Choose password-store folder",
|
||
Message::PickSettingsVault,
|
||
!form.saving,
|
||
),
|
||
]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(6),
|
||
text("Default OpenPGP key fingerprint or identity").size(13),
|
||
text_input("Default key", &form.default_key)
|
||
.on_input(Message::SettingsDefaultKeyChanged)
|
||
.on_submit(Message::SaveSettings)
|
||
.style(entry_input_style),
|
||
text("Authentication inactivity timeout in seconds (1–86400)").size(13),
|
||
text_input("Timeout seconds", &form.authentication_timeout)
|
||
.on_input(Message::SettingsTimeoutChanged)
|
||
.on_submit(Message::SaveSettings)
|
||
.style(entry_input_style),
|
||
]
|
||
.spacing(8);
|
||
content = content.push(
|
||
container(general)
|
||
.padding(14)
|
||
.width(Length::Fill)
|
||
.style(|theme| entry_field_style(theme, false)),
|
||
);
|
||
|
||
let mut commands = column![
|
||
row![
|
||
text("Command-line tools").size(18).width(Length::Fill),
|
||
utility_icon_control(
|
||
Icon::Link,
|
||
"Install CLI and TUI links",
|
||
Message::InstallCommandLinks,
|
||
!form.saving,
|
||
),
|
||
]
|
||
.align_y(iced::Alignment::Center),
|
||
text(
|
||
"Install links to the CLI and TUI embedded in this application bundle in ~/.local/bin. Existing command files or links are replaced.",
|
||
)
|
||
.size(13),
|
||
]
|
||
.spacing(8);
|
||
if let Some(status) = &form.command_links_status {
|
||
commands = commands.push(text(status).size(13));
|
||
}
|
||
content = content.push(
|
||
container(commands)
|
||
.padding(14)
|
||
.width(Length::Fill)
|
||
.style(|theme| entry_field_style(theme, false)),
|
||
);
|
||
if let Some(storage) = &app.storage {
|
||
content = content.push(
|
||
container(
|
||
column![
|
||
text("Configuration details").size(18),
|
||
text(format!(
|
||
"Shared configuration: {}",
|
||
storage.config_source().display()
|
||
))
|
||
.size(13),
|
||
text(format!(
|
||
"Clipboard cleanup remains {} seconds.",
|
||
storage.clipboard_timeout().duration().as_secs()
|
||
))
|
||
.size(13),
|
||
text(
|
||
"Save validates the repository, key material, entry tree, and authentication session before atomically replacing config.toml.",
|
||
)
|
||
.size(13),
|
||
]
|
||
.spacing(6),
|
||
)
|
||
.padding(14)
|
||
.width(Length::Fill)
|
||
.style(entry_value_style),
|
||
);
|
||
}
|
||
if let Some(error) = &form.error {
|
||
content = content.push(text(format!("Settings error: {error}")));
|
||
}
|
||
}
|
||
UtilityView::Recipients(form) => {
|
||
match form.kind {
|
||
RecipientWorkflowKind::InitializeStore => {
|
||
content = content.push(text(
|
||
"Apply the root .gpg-id policy to the currently open password-store folder.",
|
||
));
|
||
}
|
||
RecipientWorkflowKind::NewFolder => {
|
||
content = content
|
||
.push(text("Folder path"))
|
||
.push(
|
||
text_input("team/services", &form.path)
|
||
.on_input(Message::RecipientPathChanged)
|
||
.on_submit(Message::SubmitRecipient)
|
||
.style(entry_input_style),
|
||
)
|
||
.push(text(
|
||
"An upstream-compatible nested .gpg-id persists the folder and controls its recipients.",
|
||
));
|
||
}
|
||
}
|
||
content = content.push(text("Encryption recipients").size(20));
|
||
for (index, recipient) in form.recipients.iter().enumerate() {
|
||
let configured = if recipient.default {
|
||
" · previously configured default"
|
||
} else {
|
||
""
|
||
};
|
||
let choice = utility_button(text(format!(
|
||
"[{}] {}{}",
|
||
if recipient.selected { "x" } else { " " },
|
||
recipient.label,
|
||
configured
|
||
)));
|
||
let choice = if form.running {
|
||
choice
|
||
} else {
|
||
choice.on_press(Message::ToggleRecipient(index))
|
||
};
|
||
if form.kind == RecipientWorkflowKind::InitializeStore {
|
||
let selected_default = recipient.fingerprint == form.default_key;
|
||
let default = utility_button(text(if !recipient.can_default {
|
||
"Public recipient only"
|
||
} else if selected_default {
|
||
"Default key ✓"
|
||
} else {
|
||
"Use as default key"
|
||
}));
|
||
let default = if form.running || !recipient.can_default {
|
||
default
|
||
} else {
|
||
default.on_press(Message::SelectDefaultRecipient(index))
|
||
};
|
||
content = content.push(row![choice, default].spacing(8));
|
||
} else {
|
||
content = content.push(choice);
|
||
}
|
||
}
|
||
let confirmation = utility_button(text(format!(
|
||
"[{}] Replace this recipient policy and selectively re-encrypt affected entries",
|
||
if form.confirmed { "x" } else { " " }
|
||
)));
|
||
content = content.push(if form.running {
|
||
confirmation
|
||
} else {
|
||
confirmation.on_press(Message::ToggleRecipientConfirmation)
|
||
});
|
||
if let Some(error) = &form.error {
|
||
content = content.push(text(format!("Recipient error: {error}")));
|
||
}
|
||
}
|
||
UtilityView::NewEntry(form) => {
|
||
content = content.push(text("Entry path")).push(
|
||
text_input("folder/account", &form.path)
|
||
.on_input(Message::NewEntryPathChanged)
|
||
.on_submit(Message::SubmitNewEntry)
|
||
.style(entry_input_style),
|
||
);
|
||
let generate = utility_button(text(format!(
|
||
"[{}] Generate the initial password with storage policy",
|
||
if form.generate { "x" } else { " " }
|
||
)));
|
||
content = content.push(if form.running {
|
||
generate
|
||
} else {
|
||
generate.on_press(Message::ToggleNewEntryGeneration)
|
||
});
|
||
if form.generate {
|
||
content = content.push(text("Password length")).push(
|
||
text_input("25", &form.length)
|
||
.on_input(Message::NewEntryLengthChanged)
|
||
.on_submit(Message::SubmitNewEntry)
|
||
.style(entry_input_style),
|
||
);
|
||
let symbols = utility_button(text(format!(
|
||
"[{}] Letters and digits only",
|
||
if form.no_symbols { "x" } else { " " }
|
||
)));
|
||
content = content.push(if form.running {
|
||
symbols
|
||
} else {
|
||
symbols.on_press(Message::ToggleNewEntrySymbols)
|
||
});
|
||
}
|
||
content = content.push(text(
|
||
"Create opens a structured unsaved draft. Encryption and persistence happen only when Save succeeds.",
|
||
));
|
||
if let Some(error) = &form.error {
|
||
content = content.push(text(format!("New entry error: {error}")));
|
||
}
|
||
}
|
||
UtilityView::Search(form) => {
|
||
let input = text_input(
|
||
if form.mode == SearchMode::Names {
|
||
"Name substring"
|
||
} else {
|
||
"Regular expression"
|
||
},
|
||
&form.query,
|
||
)
|
||
.on_input(Message::SearchQueryChanged)
|
||
.on_submit(Message::SubmitSearch)
|
||
.style(entry_input_style);
|
||
content = if form.mode == SearchMode::Names {
|
||
let add = utility_button("Add another term");
|
||
content.push(
|
||
row![
|
||
input,
|
||
if form.running || form.query.is_empty() {
|
||
add
|
||
} else {
|
||
add.on_press(Message::AddSearchTerm)
|
||
},
|
||
]
|
||
.spacing(8),
|
||
)
|
||
} else {
|
||
content.push(input)
|
||
};
|
||
if form.mode == SearchMode::Contents {
|
||
content = content
|
||
.push(text(
|
||
"This search authenticates and decrypts through crates/storage; results are removed when the lease locks.",
|
||
))
|
||
.push(
|
||
row![
|
||
search_option("Ignore case", form.ignore_case, Message::ToggleSearchCase, form.running),
|
||
search_option("Invert match", form.invert_match, Message::ToggleSearchInvert, form.running),
|
||
search_option("Line numbers", form.line_numbers, Message::ToggleSearchLineNumbers, form.running),
|
||
search_option("Fixed strings", form.fixed_strings, Message::ToggleSearchFixedStrings, form.running),
|
||
]
|
||
.spacing(8),
|
||
);
|
||
} else {
|
||
content = content.push(text(
|
||
"Name search remains available while locked. Multiple terms use the storage crate's pass-compatible OR matching semantics.",
|
||
));
|
||
for (index, term) in form.terms.iter().enumerate() {
|
||
let remove = utility_button(text(format!("Remove term · {term}")));
|
||
content = content.push(if form.running {
|
||
remove
|
||
} else {
|
||
remove.on_press(Message::RemoveSearchTerm(index))
|
||
});
|
||
}
|
||
}
|
||
if let Some(error) = &form.error {
|
||
content = content.push(text(format!("Search error: {error}")));
|
||
}
|
||
if let Some(results) = &form.results {
|
||
content = content.push(text("Results").size(20));
|
||
match results {
|
||
SearchResults::Names(results) => {
|
||
if results.matches().is_empty() {
|
||
content = content.push(text("No matching entries or folders."));
|
||
}
|
||
for matched in results.matches() {
|
||
let kind = if matched.id().is_directory() {
|
||
"Folder"
|
||
} else {
|
||
"Entry"
|
||
};
|
||
content = content.push(
|
||
utility_button(text(format!("{kind} · {}", matched.path())))
|
||
.on_press(Message::ActivateSearchResult(matched.id().clone())),
|
||
);
|
||
}
|
||
}
|
||
SearchResults::Contents(results) => {
|
||
if results.is_empty() {
|
||
content = content.push(text("No decrypted content matches."));
|
||
}
|
||
for entry in results.entries() {
|
||
content = content.push(
|
||
utility_button(text(format!("Entry · {}", entry.path()))).on_press(
|
||
Message::ActivateSearchResult(TreeNodeId::Entry(
|
||
entry.path().clone(),
|
||
)),
|
||
),
|
||
);
|
||
for line in entry.lines() {
|
||
let prefix = if results.includes_line_numbers() {
|
||
format!("{}: ", line.number())
|
||
} else {
|
||
String::new()
|
||
};
|
||
content = content.push(text(format!(
|
||
" {prefix}{}",
|
||
String::from_utf8_lossy(line.contents().expose())
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
UtilityView::Mutation(form) => {
|
||
content = content.push(text(format!("Source: {}", form.source.path().display())));
|
||
if form.kind != MutationKind::Delete {
|
||
content = content
|
||
.push(text("Destination entry path or existing folder"))
|
||
.push(
|
||
text_input("folder/name", &form.destination)
|
||
.on_input(Message::MutationDestinationChanged)
|
||
.on_submit(Message::SubmitMutation)
|
||
.style(entry_input_style),
|
||
)
|
||
.push(text("Choose an existing destination folder"));
|
||
let mut destinations = row![].spacing(6);
|
||
for directory in app.navigation.directories() {
|
||
let path = directory.path().display().to_string();
|
||
let label = if path.is_empty() { "Store root" } else { &path };
|
||
let choice = utility_button(text(label.to_owned()));
|
||
destinations = destinations.push(if form.running {
|
||
choice
|
||
} else {
|
||
choice.on_press(Message::SelectMutationDestination(path))
|
||
});
|
||
}
|
||
let overwrite = utility_button(text(format!(
|
||
"[{}] Replace an existing destination entry",
|
||
if form.overwrite { "x" } else { " " }
|
||
)));
|
||
content = content.push(destinations.wrap()).push(if form.running {
|
||
overwrite
|
||
} else {
|
||
overwrite.on_press(Message::ToggleMutationOverwrite)
|
||
});
|
||
} else {
|
||
let confirmation = utility_button(text(format!(
|
||
"[{}] Permanently remove this {} and commit the deletion",
|
||
if form.confirmed { "x" } else { " " },
|
||
if form.source.is_directory() {
|
||
"folder"
|
||
} else {
|
||
"entry"
|
||
}
|
||
)));
|
||
content = content.push(if form.running {
|
||
confirmation
|
||
} else {
|
||
confirmation.on_press(Message::ToggleMutationConfirmation)
|
||
});
|
||
}
|
||
content = content.push(text(
|
||
"Validation, collisions, recipient-aware re-encryption, rollback, filesystem mutation, and Git commits are owned by crates/storage.",
|
||
));
|
||
if let Some(error) = &form.error {
|
||
content = content.push(text(format!("Mutation error: {error}")));
|
||
}
|
||
}
|
||
UtilityView::Git(form) => {
|
||
content = content.push(text(
|
||
"All repository, HTTPS transport, credential, merge, and conflict decisions are owned by crates/storage. No git process or credential helper is launched.",
|
||
));
|
||
if let Some(phase) = form.progress {
|
||
content = content.push(text(format!("Progress: {}", git_phase_name(phase))));
|
||
}
|
||
if let Some(error) = &form.error {
|
||
content = content.push(text(format!("Git error: {error}")));
|
||
}
|
||
if let Some(snapshot) = &form.snapshot {
|
||
content = content
|
||
.push(text(format!("Repository: {}", snapshot.root().display())))
|
||
.push(text(format!("Branch: {}", snapshot.branch())));
|
||
if let Some(remote) = snapshot.remote() {
|
||
content = content.push(text(format!(
|
||
"HTTPS remote: {} · {} · {} ahead / {} behind",
|
||
remote.name(),
|
||
remote.url(),
|
||
remote.ahead(),
|
||
remote.behind()
|
||
)));
|
||
} else {
|
||
content = content.push(text("No HTTPS remote is configured."));
|
||
}
|
||
let status = snapshot.status();
|
||
content = content.push(text(if status.is_clean() {
|
||
"Worktree: clean".to_owned()
|
||
} else {
|
||
format!(
|
||
"Worktree: {} staged / {} unstaged change(s)",
|
||
status.staged().len(),
|
||
status.unstaged().len()
|
||
)
|
||
}));
|
||
for change in status.staged() {
|
||
content = content.push(text(format!(
|
||
"Staged · {:?} · {}",
|
||
change.kind(),
|
||
change.path().display()
|
||
)));
|
||
}
|
||
for change in status.unstaged() {
|
||
content = content.push(text(format!(
|
||
"Unstaged · {:?} · {}",
|
||
change.kind(),
|
||
change.path().display()
|
||
)));
|
||
}
|
||
content = content.push(text("Recent history").size(20));
|
||
if snapshot.recent().is_empty() {
|
||
content = content.push(text("No commits yet."));
|
||
}
|
||
for commit in snapshot.recent() {
|
||
content = content.push(text(format!(
|
||
"{} · {} · {}",
|
||
&commit.id()[..commit.id().len().min(12)],
|
||
commit.author_name(),
|
||
commit.message()
|
||
)));
|
||
}
|
||
}
|
||
if !form.conflicts.is_empty() {
|
||
content = content.push(text("Merge conflicts").size(20)).push(text(
|
||
"Choose exactly one complete version for every path. Resolution is committed by crates/storage only after all choices are present.",
|
||
));
|
||
for (index, selection) in form.conflicts.iter().enumerate() {
|
||
let local = utility_button(text(
|
||
if selection.choice == Some(GitConflictChoice::Local) {
|
||
"Local ✓"
|
||
} else {
|
||
"Use local"
|
||
},
|
||
));
|
||
let remote = utility_button(text(
|
||
if selection.choice == Some(GitConflictChoice::Remote) {
|
||
"Remote ✓"
|
||
} else {
|
||
"Use remote"
|
||
},
|
||
));
|
||
content = content.push(
|
||
column![
|
||
text(format!(
|
||
"{:?} · {}",
|
||
selection.conflict.kind(),
|
||
selection.conflict.path().display()
|
||
)),
|
||
row![
|
||
local.on_press(Message::ChooseGitConflict(
|
||
index,
|
||
GitConflictChoice::Local
|
||
)),
|
||
remote.on_press(Message::ChooseGitConflict(
|
||
index,
|
||
GitConflictChoice::Remote
|
||
)),
|
||
]
|
||
.spacing(8),
|
||
]
|
||
.spacing(4),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
UtilityView::Otp(form) => {
|
||
content = content
|
||
.push(text(
|
||
"Code generation, counters, URI validation, QR payloads, entry mutation, Git commits, and clipboard policy are owned by crates/storage.",
|
||
))
|
||
.push(text("Entry path"))
|
||
.push(
|
||
text_input("folder/entry", &form.entry)
|
||
.on_input(Message::OtpEntryChanged)
|
||
.style(entry_input_style),
|
||
);
|
||
if let Some(display) = &app.sensitive.otp {
|
||
content = content
|
||
.push(
|
||
text(format!(
|
||
"{} code for {}",
|
||
match display.metadata.kind() {
|
||
OtpKind::Totp => "TOTP",
|
||
OtpKind::Hotp => "HOTP",
|
||
},
|
||
display.entry,
|
||
))
|
||
.size(20),
|
||
)
|
||
.push(
|
||
text(String::from_utf8_lossy(display.code.expose()).into_owned()).size(36),
|
||
)
|
||
.push(text(format!(
|
||
"{} · {} · {:?} · {} digits",
|
||
display.metadata.issuer().unwrap_or("unknown issuer"),
|
||
display.metadata.account(),
|
||
display.metadata.algorithm(),
|
||
display.metadata.digits(),
|
||
)));
|
||
if let Some(remaining) = display.remaining_at(display.observed_at) {
|
||
content = content.push(text(format!("Valid for {remaining}s")));
|
||
}
|
||
if let Some(counter) = display.validity.counter() {
|
||
content = content.push(text(format!("Committed HOTP counter {counter}")));
|
||
}
|
||
}
|
||
if let Some(uri) = &app.sensitive.otp_uri {
|
||
content = content
|
||
.push(text("OTP provisioning URI").size(20))
|
||
.push(text(String::from_utf8_lossy(uri.expose()).into_owned()));
|
||
}
|
||
if let Some(matrix) = &app.sensitive.otp_qr {
|
||
content = content
|
||
.push(text("OTP provisioning QR code").size(20))
|
||
.push(
|
||
canvas(QrCanvas(matrix))
|
||
.width(Length::Fixed(320.0))
|
||
.height(Length::Fixed(320.0)),
|
||
);
|
||
}
|
||
content = content
|
||
.push(text("Import or replace OTP").size(20))
|
||
.push(
|
||
text_input("otpauth://…", &form.uri)
|
||
.on_input(|value| Message::OtpUriChanged(Zeroizing::new(value)))
|
||
.style(entry_input_style),
|
||
)
|
||
.push(search_option(
|
||
"Replace an existing OTP URI",
|
||
form.replace,
|
||
Message::ToggleOtpReplace,
|
||
form.running,
|
||
));
|
||
if form.hotp_confirmation {
|
||
content = content.push(text(
|
||
"HOTP generation advances and commits the counter. This cannot be treated as a read-only refresh.",
|
||
));
|
||
}
|
||
content = content.push(search_option(
|
||
"Permanently remove the OTP URI and commit the entry",
|
||
form.remove_confirmed,
|
||
Message::ToggleOtpRemovalConfirmation,
|
||
form.running,
|
||
));
|
||
if let Some(error) = &form.error {
|
||
content = content.push(text(format!("OTP error: {error}")));
|
||
}
|
||
}
|
||
UtilityView::Kdbx(form) => {
|
||
content = content
|
||
.push(text(
|
||
"The import is additive: full mode adds new entries and updates changed entries; quick-add mode only adds entries that do not exist. Nothing is deleted.",
|
||
))
|
||
.push(text("KDBX database"))
|
||
.push(
|
||
row![
|
||
text_input("Database.kdbx", &form.source)
|
||
.on_input(Message::KdbxSourceChanged)
|
||
.on_submit(Message::SubmitKdbxImport)
|
||
.style(entry_input_style),
|
||
if form.running {
|
||
utility_button("Choose…")
|
||
} else {
|
||
utility_button("Choose…").on_press(Message::PickKdbxSource)
|
||
},
|
||
]
|
||
.spacing(8),
|
||
)
|
||
.push(text("Optional KeePass key file"))
|
||
.push(
|
||
row![
|
||
text_input("No key file", &form.key_file)
|
||
.on_input(Message::KdbxKeyFileChanged)
|
||
.on_submit(Message::SubmitKdbxImport)
|
||
.style(entry_input_style),
|
||
if form.running {
|
||
utility_button("Choose…")
|
||
} else {
|
||
utility_button("Choose…").on_press(Message::PickKdbxKeyFile)
|
||
},
|
||
]
|
||
.spacing(8),
|
||
)
|
||
.push(text("Database password"))
|
||
.push(
|
||
text_input("Password", &form.password)
|
||
.secure(true)
|
||
.on_input(|value| Message::KdbxPasswordChanged(Zeroizing::new(value)))
|
||
.on_submit(Message::SubmitKdbxImport)
|
||
.style(entry_input_style),
|
||
)
|
||
.push(search_option(
|
||
"Quick add: only add entries not already present",
|
||
form.quick_add,
|
||
Message::ToggleKdbxQuickAdd,
|
||
form.running,
|
||
))
|
||
.push(search_option(
|
||
"Confirm additive import and per-entry Git commits",
|
||
form.confirmed,
|
||
Message::ToggleKdbxConfirmation,
|
||
form.running,
|
||
));
|
||
if let Some(summary) = &form.summary {
|
||
content = content.push(text(summary));
|
||
}
|
||
if let Some(error) = &form.error {
|
||
content = content.push(text(format!("KDBX import error: {error}")));
|
||
}
|
||
}
|
||
UtilityView::Help => {
|
||
content = content.push(text("Keyboard shortcuts").size(22));
|
||
for spec in action::ACTIONS {
|
||
if let Some(shortcut) = action::shortcut_label(spec.action) {
|
||
content = content.push(text(format!(
|
||
"{shortcut} {} · {}",
|
||
spec.label,
|
||
spec.group.label()
|
||
)));
|
||
}
|
||
}
|
||
content = content
|
||
.push(text("Two-pane navigation").size(22))
|
||
.push(text(
|
||
"The left pane navigates the storage-provided folder tree. The right pane opens the selected entry. Tab changes pane focus; arrows, Home, End, and Enter navigate the focused pane.",
|
||
))
|
||
.push(text("Sensitive values, copying, and locking").size(22))
|
||
.push(text(
|
||
"Opening protected content authenticates through the shared inactivity lease. Every field remains visible while unlocked, and Copy uses the configured cleanup timeout. Lock immediately drops decrypted entry, editor, OTP code, and clipboard state.",
|
||
))
|
||
.push(text("Command palette").size(22))
|
||
.push(text(
|
||
"Open the command palette with its platform shortcut, type a command or alias, use Up/Down to select, Enter to run, and Escape to cancel. Unavailable commands explain why they are disabled.",
|
||
))
|
||
.push(text("Git actions").size(22))
|
||
.push(text(
|
||
"Fetch, Pull, Push, status, and conflict actions use the embedded storage Git implementation. Remotes are HTTPS-only; conflicts require an explicit choice and are never silently discarded.",
|
||
))
|
||
.push(text("OTP actions").size(22))
|
||
.push(text(
|
||
"OTP actions operate on storage-recognized fields. TOTP codes are time-based; HOTP generation confirms and commits the counter advance. URI, QR, and clipboard outputs are explicit sensitive presentations.",
|
||
));
|
||
}
|
||
}
|
||
let busy = matches!(utility, UtilityView::Settings(form) if form.saving)
|
||
|| matches!(utility, UtilityView::Recipients(form) if form.running)
|
||
|| matches!(utility, UtilityView::NewEntry(form) if form.running)
|
||
|| matches!(utility, UtilityView::Search(form) if form.running)
|
||
|| matches!(utility, UtilityView::Mutation(form) if form.running)
|
||
|| matches!(utility, UtilityView::Git(form) if form.running)
|
||
|| matches!(utility, UtilityView::Otp(form) if form.running)
|
||
|| matches!(utility, UtilityView::Kdbx(form) if form.running);
|
||
let actions = match utility {
|
||
UtilityView::Settings(form) => row![utility_icon_control(
|
||
Icon::Check,
|
||
if form.saving {
|
||
"Validating settings…"
|
||
} else {
|
||
"Save settings"
|
||
},
|
||
Message::SaveSettings,
|
||
!form.saving,
|
||
)],
|
||
UtilityView::Recipients(form) => row![utility_icon_control(
|
||
Icon::Check,
|
||
if form.running {
|
||
"Applying recipient policy…"
|
||
} else {
|
||
"Apply recipient policy"
|
||
},
|
||
Message::SubmitRecipient,
|
||
!form.running,
|
||
)],
|
||
UtilityView::NewEntry(form) => row![utility_icon_control(
|
||
Icon::Check,
|
||
if form.running {
|
||
"Preparing draft…"
|
||
} else {
|
||
"Create draft"
|
||
},
|
||
Message::SubmitNewEntry,
|
||
!form.running,
|
||
)],
|
||
UtilityView::Search(form) => row![utility_icon_control(
|
||
Icon::Search,
|
||
if form.running {
|
||
"Searching…"
|
||
} else {
|
||
"Search"
|
||
},
|
||
Message::SubmitSearch,
|
||
!form.running,
|
||
)],
|
||
UtilityView::Mutation(form) => {
|
||
let icon = match form.kind {
|
||
MutationKind::Move => Icon::Move,
|
||
MutationKind::Copy => Icon::Copy,
|
||
MutationKind::Delete => Icon::Delete,
|
||
};
|
||
row![utility_icon_control(
|
||
icon,
|
||
if form.running {
|
||
"Applying mutation…"
|
||
} else {
|
||
"Apply mutation"
|
||
},
|
||
Message::SubmitMutation,
|
||
!form.running,
|
||
)]
|
||
}
|
||
UtilityView::Git(form) => {
|
||
if form.running {
|
||
row![utility_button("Cancel Git operation").on_press(Message::CancelGit)]
|
||
} else {
|
||
let mut actions = row![
|
||
utility_icon_control(
|
||
Icon::Refresh,
|
||
"Refresh Git status",
|
||
Message::RunGit(DesktopGitRequest::Refresh),
|
||
true,
|
||
),
|
||
utility_icon_control(
|
||
Icon::Down,
|
||
"Pull from remote",
|
||
Message::RunGit(DesktopGitRequest::Pull),
|
||
true,
|
||
),
|
||
utility_icon_control(
|
||
Icon::Up,
|
||
"Push to remote",
|
||
Message::RunGit(DesktopGitRequest::Push),
|
||
true,
|
||
),
|
||
utility_icon_control(
|
||
Icon::Refresh,
|
||
"Synchronize with remote",
|
||
Message::RunGit(DesktopGitRequest::Sync),
|
||
true,
|
||
),
|
||
]
|
||
.spacing(2);
|
||
if !form.conflicts.is_empty() {
|
||
actions = actions.push(utility_icon_control(
|
||
Icon::Check,
|
||
"Resolve selected versions",
|
||
Message::ResolveGitConflicts,
|
||
true,
|
||
));
|
||
}
|
||
actions
|
||
}
|
||
}
|
||
UtilityView::Otp(form) => {
|
||
if form.running {
|
||
row![utility_icon_control(
|
||
Icon::Refresh,
|
||
"Working…",
|
||
Message::RunOtpCode(false),
|
||
false,
|
||
)]
|
||
} else {
|
||
let mut actions = row![
|
||
utility_icon_control(
|
||
Icon::Refresh,
|
||
"Generate OTP code",
|
||
Message::RunOtpCode(false),
|
||
true,
|
||
),
|
||
utility_icon_control(
|
||
Icon::Copy,
|
||
"Copy OTP code",
|
||
Message::RunOtpCode(true),
|
||
true,
|
||
),
|
||
utility_icon_control(
|
||
Icon::Link,
|
||
"Show provisioning URI",
|
||
Message::RunOtpUri {
|
||
qr: false,
|
||
copy: false,
|
||
},
|
||
true,
|
||
),
|
||
utility_icon_control(
|
||
Icon::Copy,
|
||
"Copy provisioning URI",
|
||
Message::RunOtpUri {
|
||
qr: false,
|
||
copy: true,
|
||
},
|
||
true,
|
||
),
|
||
utility_icon_control(
|
||
Icon::Qr,
|
||
"Show provisioning QR code",
|
||
Message::RunOtpUri {
|
||
qr: true,
|
||
copy: false,
|
||
},
|
||
true,
|
||
),
|
||
]
|
||
.spacing(2);
|
||
if form.hotp_confirmation {
|
||
actions = actions.push(
|
||
utility_button("Confirm HOTP counter advance")
|
||
.on_press(Message::ConfirmHotp),
|
||
);
|
||
}
|
||
actions
|
||
.push(utility_icon_control(
|
||
Icon::Down,
|
||
"Import provisioning URI",
|
||
Message::SubmitOtpImport,
|
||
true,
|
||
))
|
||
.push(utility_icon_control(
|
||
Icon::Qr,
|
||
"Import provisioning QR image",
|
||
Message::PickOtpQr,
|
||
true,
|
||
))
|
||
.push(
|
||
utility_button("Remove OTP")
|
||
.style(button::danger)
|
||
.on_press(Message::SubmitOtpRemoval),
|
||
)
|
||
}
|
||
}
|
||
UtilityView::Kdbx(form) => row![utility_icon_control(
|
||
Icon::Down,
|
||
if form.running {
|
||
"Importing…"
|
||
} else if form.quick_add {
|
||
"Quick add KeePass entries"
|
||
} else {
|
||
"Import KeePass entries"
|
||
},
|
||
Message::SubmitKdbxImport,
|
||
!form.running,
|
||
)],
|
||
UtilityView::About | UtilityView::Help => row![container(text("")).width(Length::Fill)],
|
||
};
|
||
|
||
let header = container(
|
||
row![
|
||
colored_icon_view(utility_icon(utility), Some(ENTRY_TEXT)),
|
||
text(utility_title(utility)).size(20).width(Length::Fill),
|
||
utility_icon_control(
|
||
Icon::Close,
|
||
"Close panel (Esc)",
|
||
Message::DismissUtility,
|
||
!busy,
|
||
),
|
||
]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(8),
|
||
)
|
||
.padding([6, 10])
|
||
.width(Length::Fill)
|
||
.style(entry_value_style);
|
||
let body = scrollable(
|
||
container(
|
||
container(content)
|
||
.padding(16)
|
||
.width(Length::Fill)
|
||
.max_width(760)
|
||
.style(|theme| entry_field_style(theme, false)),
|
||
)
|
||
.padding([12, 16])
|
||
.width(Length::Fill)
|
||
.center_x(Length::Fill),
|
||
)
|
||
.height(Length::Fill);
|
||
let mut layout = column![header, body].spacing(1);
|
||
if !matches!(utility, UtilityView::About | UtilityView::Help) {
|
||
layout = layout.push(
|
||
container(
|
||
row![container(text("")).width(Length::Fill), actions]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(2),
|
||
)
|
||
.padding([6, 10])
|
||
.width(Length::Fill)
|
||
.style(entry_value_style),
|
||
);
|
||
}
|
||
container(layout)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.style(entry_area_style)
|
||
.into()
|
||
}
|
||
|
||
fn search_option<'a>(
|
||
label: &'a str,
|
||
selected: bool,
|
||
message: Message,
|
||
running: bool,
|
||
) -> iced::widget::Button<'a, Message> {
|
||
let option = utility_button(text(format!(
|
||
"[{}] {label}",
|
||
if selected { "x" } else { " " }
|
||
)));
|
||
if running {
|
||
option
|
||
} else {
|
||
option.on_press(message)
|
||
}
|
||
}
|
||
|
||
struct QrCanvas<'a>(&'a QrMatrix);
|
||
|
||
impl<Message> canvas::Program<Message> for QrCanvas<'_> {
|
||
type State = ();
|
||
|
||
fn draw(
|
||
&self,
|
||
_state: &Self::State,
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: iced::mouse::Cursor,
|
||
) -> Vec<canvas::Geometry> {
|
||
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
||
frame.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
|
||
let padded = self.0.width() + 8;
|
||
let scale = (bounds.width.min(bounds.height) / padded as f32).floor();
|
||
let offset = Point::new(
|
||
(bounds.width - scale * padded as f32) / 2.0,
|
||
(bounds.height - scale * padded as f32) / 2.0,
|
||
);
|
||
for y in 0..self.0.width() {
|
||
for x in 0..self.0.width() {
|
||
if self.0.is_dark(x, y) == Some(true) {
|
||
frame.fill_rectangle(
|
||
Point::new(
|
||
offset.x + (x + 4) as f32 * scale,
|
||
offset.y + (y + 4) as f32 * scale,
|
||
),
|
||
iced::Size::new(scale, scale),
|
||
Color::BLACK,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
vec![frame.into_geometry()]
|
||
}
|
||
}
|
||
|
||
fn sidebar_view<'a>(
|
||
navigation: &'a NavigationTree,
|
||
state: &'a TreeState,
|
||
focused: bool,
|
||
context_target: Option<&'a TreeNodeId>,
|
||
) -> Element<'a, Message> {
|
||
let mut rows = column![
|
||
row![
|
||
text(if focused { "EXPLORER •" } else { "EXPLORER" })
|
||
.size(12)
|
||
.width(Length::Fill),
|
||
action_icon(Icon::Add, UiAction::NewEntry),
|
||
action_icon(Icon::FolderAdd, UiAction::NewFolder),
|
||
action_icon(Icon::Refresh, UiAction::Refresh),
|
||
]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(2)
|
||
]
|
||
.spacing(4)
|
||
.padding([6, 4]);
|
||
|
||
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 expander: Element<'static, Message> = if node.id.is_directory() {
|
||
icon_view(if node.expanded {
|
||
Icon::ChevronDown
|
||
} else {
|
||
Icon::ChevronRight
|
||
})
|
||
} else {
|
||
container(text(""))
|
||
.width(Length::Fixed(16.0))
|
||
.height(Length::Fixed(16.0))
|
||
.into()
|
||
};
|
||
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 id = node.id.clone();
|
||
let kind = if node.id.is_directory() {
|
||
Icon::Folder
|
||
} else {
|
||
Icon::Key
|
||
};
|
||
let count = node
|
||
.id
|
||
.is_directory()
|
||
.then(|| text(node.entry_count.to_string()).size(12));
|
||
let mut item_row = row![
|
||
container(text("")).width(Length::Fixed((node.depth * 16) as f32)),
|
||
expander,
|
||
icon_view(kind),
|
||
text(format!("{}{}", node.name, suffix)).width(Length::Fill),
|
||
]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(4);
|
||
if let Some(count) = count {
|
||
item_row = item_row.push(count);
|
||
}
|
||
let item = button(item_row)
|
||
.width(Length::Fill)
|
||
.on_press(Message::SidebarActivate(id.clone()))
|
||
.style(if selected {
|
||
selected_button
|
||
} else {
|
||
button::text
|
||
});
|
||
rows = rows.push(mouse_area(item).on_right_press(Message::SidebarContext(id.clone())));
|
||
if context_target == Some(&id) {
|
||
rows = rows.push(
|
||
row![
|
||
container(text("")).width(Length::Fixed(((node.depth + 1) * 16) as f32)),
|
||
icon_control(
|
||
Icon::Move,
|
||
action_hint(UiAction::MoveEntry),
|
||
Message::SidebarContextAction(id.clone(), UiAction::MoveEntry),
|
||
),
|
||
icon_control(
|
||
Icon::Copy,
|
||
action_hint(UiAction::CopyEntry),
|
||
Message::SidebarContextAction(id.clone(), UiAction::CopyEntry),
|
||
),
|
||
icon_control(
|
||
Icon::Delete,
|
||
action_hint(UiAction::DeleteEntry),
|
||
Message::SidebarContextAction(id, UiAction::DeleteEntry),
|
||
),
|
||
]
|
||
.spacing(2),
|
||
);
|
||
}
|
||
}
|
||
|
||
scrollable(rows)
|
||
.id(sidebar_scroll_id())
|
||
.height(Length::Fill)
|
||
.into()
|
||
}
|
||
|
||
fn content_view(app: &App) -> Element<'_, Message> {
|
||
let mut identity = row![]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(6)
|
||
.width(Length::Fill);
|
||
if !app.entry_path.is_empty() {
|
||
identity = identity.push(icon_view(Icon::Key));
|
||
}
|
||
identity = identity.push(
|
||
text(if app.entry_path.is_empty() {
|
||
if app.pane_focus == PaneFocus::Content {
|
||
"CONTENT •"
|
||
} else {
|
||
"CONTENT"
|
||
}
|
||
} else {
|
||
&app.entry_path
|
||
})
|
||
.size(15)
|
||
.width(Length::Fill),
|
||
);
|
||
let header = row![
|
||
identity,
|
||
action_icon(Icon::Edit, UiAction::EditEntry),
|
||
action_icon(Icon::Refresh, UiAction::ReloadEntry),
|
||
]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(2);
|
||
|
||
let body: Element<'_, Message> = if app.switching_vault {
|
||
container(text(
|
||
"Validating the selected password store and shared configuration…",
|
||
))
|
||
.center(Length::Fill)
|
||
.into()
|
||
} else 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(app, 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![header, body].spacing(6).padding([6, 8]))
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.into()
|
||
}
|
||
|
||
fn authentication_allows_content(authentication: &AuthenticationView) -> bool {
|
||
matches!(authentication, AuthenticationView::Unlocked(_))
|
||
}
|
||
|
||
fn document_has_single_totp(document: &EntryDocument) -> bool {
|
||
let mut otp = document
|
||
.fields()
|
||
.iter()
|
||
.filter_map(|field| field.metadata().otp());
|
||
otp.next()
|
||
.is_some_and(|metadata| metadata.kind() == OtpKind::Totp)
|
||
&& otp.next().is_none()
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq)]
|
||
enum ViewerValue<'a> {
|
||
Text(&'a str),
|
||
Unavailable,
|
||
}
|
||
|
||
fn viewer_value(field: &EntryField) -> ViewerValue<'_> {
|
||
std::str::from_utf8(field.value())
|
||
.map(ViewerValue::Text)
|
||
.unwrap_or(ViewerValue::Unavailable)
|
||
}
|
||
|
||
fn viewer_label(field: &EntryField) -> String {
|
||
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(),
|
||
},
|
||
str::to_owned,
|
||
)
|
||
}
|
||
|
||
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 otp_code_text(display: &OtpDisplay) -> String {
|
||
std::str::from_utf8(display.code.expose()).map_or_else(
|
||
|_| "(unavailable)".to_owned(),
|
||
|code| {
|
||
let middle = code.len() / 2;
|
||
if code.len() > 4 && code.is_char_boundary(middle) {
|
||
format!("{} {}", &code[..middle], &code[middle..])
|
||
} else {
|
||
code.to_owned()
|
||
}
|
||
},
|
||
)
|
||
}
|
||
|
||
fn matching_otp_display<'a>(app: &'a App, entry: &str) -> Option<&'a OtpDisplay> {
|
||
app.sensitive
|
||
.otp
|
||
.as_ref()
|
||
.filter(|display| display.entry == entry)
|
||
}
|
||
|
||
fn otp_progress(display: &OtpDisplay) -> Option<(u64, u64)> {
|
||
display.validity.period().map(|period| {
|
||
(
|
||
display
|
||
.remaining_at(display.observed_at)
|
||
.unwrap_or_default()
|
||
.min(period),
|
||
period.max(1),
|
||
)
|
||
})
|
||
}
|
||
|
||
fn viewer_view<'a>(app: &'a App, editor: &'a EntryEditor) -> Element<'a, Message> {
|
||
let entry = editor.entry();
|
||
let mut rows = column![].spacing(8);
|
||
if let Some(display) = matching_otp_display(app, &entry) {
|
||
let mut code = column![
|
||
text(match display.metadata.kind() {
|
||
OtpKind::Totp => "Current one-time password",
|
||
OtpKind::Hotp => "Generated one-time password",
|
||
})
|
||
.size(13),
|
||
text(otp_code_text(display)).size(42),
|
||
]
|
||
.align_x(iced::Alignment::Center)
|
||
.spacing(2);
|
||
if let Some((remaining, period)) = otp_progress(display) {
|
||
code = code.push(text(format!("{remaining}s remaining"))).push(
|
||
progress_bar(0.0..=period as f32, remaining as f32).girth(Length::Fixed(6.0)),
|
||
);
|
||
} else if let Some(counter) = display.validity.counter() {
|
||
code = code.push(text(format!("HOTP counter {counter}")));
|
||
}
|
||
rows = rows.push(
|
||
container(code)
|
||
.padding([10, 12])
|
||
.width(Length::Fill)
|
||
.center_x(Length::Fill)
|
||
.style(entry_value_style),
|
||
);
|
||
} else if app.otp_pending
|
||
&& editor
|
||
.document()
|
||
.fields()
|
||
.iter()
|
||
.any(|field| field.metadata().otp().is_some())
|
||
{
|
||
rows = rows.push(
|
||
container(text("Generating one-time password…").size(15))
|
||
.padding(10)
|
||
.width(Length::Fill)
|
||
.center_x(Length::Fill)
|
||
.style(entry_value_style),
|
||
);
|
||
}
|
||
rows =
|
||
rows.push(text("Up/Down/Home/End select fields · ⌘C copies the selected value").size(12));
|
||
|
||
for field in editor.document().display_fields() {
|
||
let id = field.id();
|
||
let label = viewer_label(field);
|
||
let selected = editor.focused() == Some(id);
|
||
let value = match viewer_value(field) {
|
||
ViewerValue::Text("") => "(empty)",
|
||
ViewerValue::Text(value) => value,
|
||
ViewerValue::Unavailable => "(binary value)",
|
||
};
|
||
let copy_hint = format!("Copy {label} (⌘C)");
|
||
let copy = entry_icon_control(
|
||
Icon::Copy,
|
||
copy_hint,
|
||
Message::FieldAction(id, UiAction::CopyField),
|
||
);
|
||
let mut field_view = if value.contains('\n') {
|
||
column![
|
||
row![text(label).size(13).width(Length::Fill), copy,]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(4),
|
||
container(text(value).size(15).wrapping(text::Wrapping::WordOrGlyph),)
|
||
.padding(8)
|
||
.width(Length::Fill)
|
||
.style(entry_value_style),
|
||
]
|
||
.spacing(4)
|
||
} else {
|
||
column![
|
||
row![
|
||
text(label).size(13).width(Length::Fixed(ENTRY_LABEL_WIDTH)),
|
||
text(value)
|
||
.size(15)
|
||
.width(Length::Fill)
|
||
.wrapping(text::Wrapping::WordOrGlyph),
|
||
copy,
|
||
]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(8),
|
||
]
|
||
};
|
||
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(),
|
||
))
|
||
.size(12),
|
||
);
|
||
field_view = field_view.push(
|
||
container(
|
||
row![
|
||
entry_icon_control(
|
||
Icon::Refresh,
|
||
"Generate current OTP code".to_owned(),
|
||
Message::FieldAction(id, UiAction::GenerateOtp),
|
||
),
|
||
entry_icon_control(
|
||
Icon::Copy,
|
||
"Copy current OTP code".to_owned(),
|
||
Message::FieldAction(id, UiAction::CopyOtp),
|
||
),
|
||
entry_icon_control(
|
||
Icon::Link,
|
||
"Copy OTP provisioning URI".to_owned(),
|
||
Message::FieldAction(id, UiAction::CopyOtpUri),
|
||
),
|
||
entry_icon_control(
|
||
Icon::Qr,
|
||
"Show OTP provisioning QR code".to_owned(),
|
||
Message::FieldAction(id, UiAction::ShowOtpQr),
|
||
),
|
||
]
|
||
.spacing(2),
|
||
)
|
||
.align_right(Length::Fill),
|
||
);
|
||
}
|
||
if let Some(diagnostic) = field.metadata().diagnostic() {
|
||
field_view = field_view.push(text(viewer_diagnostic(diagnostic)).size(12));
|
||
}
|
||
rows = rows.push(
|
||
mouse_area(
|
||
container(field_view)
|
||
.padding([7, 9])
|
||
.width(Length::Fill)
|
||
.style(move |theme| entry_field_style(theme, selected)),
|
||
)
|
||
.on_press(Message::SelectField(id)),
|
||
);
|
||
}
|
||
|
||
container(scrollable(rows).id(viewer_scroll_id()).height(Length::Fill))
|
||
.padding([8, 10])
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.style(entry_area_style)
|
||
.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(16)
|
||
.width(Length::Fill),
|
||
entry_icon_control(
|
||
Icon::Check,
|
||
action_hint(UiAction::Save),
|
||
Message::Action(UiAction::Save),
|
||
),
|
||
entry_icon_control(Icon::Add, "Add field".to_owned(), Message::AddAfter(None),),
|
||
]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(2)
|
||
]
|
||
.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 selected = editor.focused() == Some(id);
|
||
let sensitive = field.metadata().sensitivity() == EntrySensitivity::Sensitive;
|
||
let value = std::str::from_utf8(field.value()).ok();
|
||
let label = field
|
||
.metadata()
|
||
.name()
|
||
.map(str::to_owned)
|
||
.unwrap_or_else(|| format!("{:?}", field.metadata().kind()));
|
||
let mut actions = row![
|
||
entry_icon_control(Icon::Up, "Move field up".to_owned(), Message::MoveUp(id)),
|
||
entry_icon_control(
|
||
Icon::Down,
|
||
"Move field down".to_owned(),
|
||
Message::MoveDown(id)
|
||
),
|
||
entry_icon_control(
|
||
Icon::Add,
|
||
"Add field below".to_owned(),
|
||
Message::AddAfter(Some(id)),
|
||
),
|
||
entry_icon_control(Icon::Delete, "Remove field".to_owned(), Message::Remove(id)),
|
||
]
|
||
.spacing(2);
|
||
if sensitive && field.metadata().kind() != EntryFieldKind::OtpUri {
|
||
actions = actions.push(entry_icon_control(
|
||
Icon::Generate,
|
||
"Generate password".to_owned(),
|
||
Message::FieldAction(id, UiAction::GeneratePassword),
|
||
));
|
||
}
|
||
actions = actions.push(entry_icon_control(
|
||
Icon::Copy,
|
||
"Copy field value (⌘C)".to_owned(),
|
||
Message::FieldAction(id, UiAction::CopyEditedField),
|
||
));
|
||
|
||
let field_view = match value {
|
||
Some(value) if value.contains('\n') => {
|
||
let input: Element<'_, Message> = text_editor(
|
||
editor
|
||
.multiline_content(id)
|
||
.expect("multiline fields have one editor content"),
|
||
)
|
||
.id(editor_field_input_id(id))
|
||
.height(Length::Fixed(120.0))
|
||
.on_action(move |action| Message::FieldEdited(id, action))
|
||
.style(entry_editor_style)
|
||
.into();
|
||
column![
|
||
row![text(label).size(14).width(Length::Fill), actions,]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(4),
|
||
input,
|
||
]
|
||
.spacing(4)
|
||
}
|
||
Some(value) => {
|
||
let submit = if matches!(
|
||
field.metadata().kind(),
|
||
EntryFieldKind::Password | EntryFieldKind::OtpUri
|
||
) {
|
||
Message::AddAfter(Some(id))
|
||
} else {
|
||
Message::AddFieldLine(id, 0)
|
||
};
|
||
let input: Element<'_, Message> = text_input("Entry value", value)
|
||
.id(editor_field_input_id(id))
|
||
.on_input(move |value| Message::FieldChanged(id, Zeroizing::new(value)))
|
||
.on_submit(submit)
|
||
.style(entry_input_style)
|
||
.into();
|
||
column![
|
||
row![
|
||
text(label).size(14).width(Length::Fixed(ENTRY_LABEL_WIDTH)),
|
||
input,
|
||
actions,
|
||
]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(8),
|
||
]
|
||
}
|
||
None => {
|
||
let input: Element<'_, Message> = text_input("Non-UTF-8 value preserved", "")
|
||
.style(entry_input_style)
|
||
.into();
|
||
column![
|
||
row![
|
||
text(label).size(14).width(Length::Fixed(ENTRY_LABEL_WIDTH)),
|
||
input,
|
||
actions,
|
||
]
|
||
.align_y(iced::Alignment::Center)
|
||
.spacing(8),
|
||
]
|
||
}
|
||
};
|
||
fields = fields.push(
|
||
mouse_area(
|
||
container(field_view)
|
||
.padding([7, 9])
|
||
.width(Length::Fill)
|
||
.style(move |theme| entry_field_style(theme, selected)),
|
||
)
|
||
.on_press(Message::SelectField(id)),
|
||
);
|
||
}
|
||
container(scrollable(fields).height(Length::Fill))
|
||
.padding([8, 10])
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.style(entry_area_style)
|
||
.into()
|
||
}
|
||
|
||
fn confirmation_view(action: &PendingAction) -> Element<'_, Message> {
|
||
let description = match action {
|
||
PendingAction::OpenVault(path) => format!("Open {}", path.display()),
|
||
PendingAction::OpenEntry(entry) => format!("Open {entry}"),
|
||
PendingAction::Reload(entry) => format!("Reload {entry}"),
|
||
PendingAction::CloseWindow(_) => "Close IronStorage".to_owned(),
|
||
PendingAction::ApplyRecipients(form) => match form.kind {
|
||
RecipientWorkflowKind::InitializeStore => "Initialize the store".to_owned(),
|
||
RecipientWorkflowKind::NewFolder => format!("Create folder {}", form.path),
|
||
},
|
||
PendingAction::CreateEntry(form) => format!("Create entry {}", form.path),
|
||
PendingAction::SearchContents(request) => {
|
||
format!("Search decrypted contents for {}", request.pattern)
|
||
}
|
||
PendingAction::Mutate(form) => format!(
|
||
"{} {}",
|
||
match form.kind {
|
||
MutationKind::Move => "Move",
|
||
MutationKind::Copy => "Copy",
|
||
MutationKind::Delete => "Delete",
|
||
},
|
||
form.source.path().display()
|
||
),
|
||
PendingAction::Git(request) => format!("Git {}", git_request_name(request)),
|
||
PendingAction::ImportKdbx(form) => format!("Import {}", form.source),
|
||
};
|
||
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 git_request_name(request: &DesktopGitRequest) -> &'static str {
|
||
match request {
|
||
DesktopGitRequest::Refresh => "status refresh",
|
||
DesktopGitRequest::Pull => "pull",
|
||
DesktopGitRequest::Push => "push",
|
||
DesktopGitRequest::Sync => "synchronization",
|
||
DesktopGitRequest::Resolve(_) => "conflict resolution",
|
||
}
|
||
}
|
||
|
||
fn git_phase_name(phase: GitProgressPhase) -> &'static str {
|
||
match phase {
|
||
GitProgressPhase::Validating => "validating the repository and HTTPS remote",
|
||
GitProgressPhase::Authenticating => "requesting secure HTTPS credentials",
|
||
GitProgressPhase::Receiving => "receiving remote objects",
|
||
GitProgressPhase::Integrating => "integrating fetched changes",
|
||
GitProgressPhase::Sending => "sending local objects",
|
||
GitProgressPhase::Refreshing => "refreshing repository state",
|
||
}
|
||
}
|
||
|
||
fn git_outcome_message(outcome: &DesktopGitOutcome) -> String {
|
||
match outcome {
|
||
DesktopGitOutcome::Refreshed => "Git status and history refreshed.".to_owned(),
|
||
DesktopGitOutcome::Pulled(outcome) => format!("Git pull completed: {outcome:?}."),
|
||
DesktopGitOutcome::Pushed(outcome) => format!(
|
||
"Pushed {} branch {} at {}.",
|
||
outcome.remote(),
|
||
outcome.branch(),
|
||
&outcome.new_id()[..outcome.new_id().len().min(12)]
|
||
),
|
||
DesktopGitOutcome::Synchronized { pull, push } => format!(
|
||
"Git synchronization completed: {pull:?}; pushed {} at {}.",
|
||
push.remote(),
|
||
&push.new_id()[..push.new_id().len().min(12)]
|
||
),
|
||
DesktopGitOutcome::Resolved(outcome) => {
|
||
format!("Git conflicts resolved and committed: {outcome:?}.")
|
||
}
|
||
}
|
||
}
|
||
|
||
fn git_outcome_changes_worktree(outcome: &DesktopGitOutcome) -> bool {
|
||
match outcome {
|
||
DesktopGitOutcome::Pulled(outcome) => {
|
||
!matches!(outcome, ironstorage::git::PullOutcome::UpToDate)
|
||
}
|
||
DesktopGitOutcome::Synchronized { pull, .. } => {
|
||
!matches!(pull, ironstorage::git::PullOutcome::UpToDate)
|
||
}
|
||
DesktopGitOutcome::Resolved(_) => true,
|
||
DesktopGitOutcome::Refreshed | DesktopGitOutcome::Pushed(_) => false,
|
||
}
|
||
}
|
||
|
||
fn git_failure_message(error: &DesktopError) -> String {
|
||
match error.git_error() {
|
||
Some(GitError::NotRepository) => {
|
||
"No embedded Git repository exists for this password store.".to_owned()
|
||
}
|
||
Some(GitError::ForbiddenRemoteUrl) => {
|
||
"The repository remote must match the configured credential-free HTTPS URL. Fix the shared configuration or repository remote, then refresh.".to_owned()
|
||
}
|
||
Some(GitError::RemoteNotFound { name }) => {
|
||
format!("The configured HTTPS remote {name} is missing from the repository.")
|
||
}
|
||
Some(GitError::CredentialsUnavailable) => {
|
||
"HTTPS credentials are unavailable in secure storage for the configured server and application.".to_owned()
|
||
}
|
||
Some(GitError::CredentialAccessDenied) => {
|
||
"Access to the HTTPS credential was denied; retry and approve the secure-storage request.".to_owned()
|
||
}
|
||
Some(GitError::CredentialCancelled) => {
|
||
"The HTTPS credential request was cancelled; retry when ready.".to_owned()
|
||
}
|
||
Some(GitError::AuthenticationFailed) => {
|
||
"The HTTPS server rejected the stored credential; update it in secure storage and retry.".to_owned()
|
||
}
|
||
Some(GitError::NetworkUnavailable) => {
|
||
"The HTTPS Git server is unreachable; check the network and retry.".to_owned()
|
||
}
|
||
Some(GitError::TlsFailed) => {
|
||
"TLS validation failed for the HTTPS Git server; verify its certificate and configured URL.".to_owned()
|
||
}
|
||
Some(GitError::Cancelled) => "The Git operation was cancelled safely.".to_owned(),
|
||
Some(GitError::NonFastForward) => {
|
||
"The push is not a fast-forward; pull and resolve remote changes before retrying."
|
||
.to_owned()
|
||
}
|
||
Some(GitError::DirtyWorktree) => {
|
||
"The repository has uncommitted changes; commit or restore them before synchronization."
|
||
.to_owned()
|
||
}
|
||
Some(GitError::InvalidConflictResolution { path }) => format!(
|
||
"The conflict choice for {} is no longer valid; refresh conflicts and choose again.",
|
||
path.display()
|
||
),
|
||
_ => error.to_string(),
|
||
}
|
||
}
|
||
|
||
fn generation_view(form: &GenerateForm) -> Element<'_, Message> {
|
||
let mut content = column![
|
||
text(if form.replacement {
|
||
"Replace with Generated Password"
|
||
} else {
|
||
"Generate Password"
|
||
})
|
||
.size(26),
|
||
text("Password length"),
|
||
text_input("25", &form.length)
|
||
.on_input(Message::GenerateLengthChanged)
|
||
.on_submit(Message::SubmitGenerate),
|
||
button(text(format!(
|
||
"[{}] Letters and digits only",
|
||
if form.no_symbols { "x" } else { " " }
|
||
)))
|
||
.on_press(Message::ToggleGenerateSymbols),
|
||
]
|
||
.spacing(12);
|
||
if form.replacement {
|
||
content = content.push(
|
||
button(text(format!(
|
||
"[{}] Replace the current field value",
|
||
if form.confirmed { "x" } else { " " }
|
||
)))
|
||
.on_press(Message::ToggleGenerateConfirmation),
|
||
);
|
||
}
|
||
if let Some(error) = &form.error {
|
||
content = content.push(text(format!("Generation error: {error}")));
|
||
}
|
||
content = content
|
||
.push(text(format!("Field {}", form.id.value())).size(12))
|
||
.push(
|
||
row![
|
||
button(if form.replacement {
|
||
"Generate and Replace"
|
||
} else {
|
||
"Generate"
|
||
})
|
||
.on_press(Message::SubmitGenerate),
|
||
button("Cancel").on_press(Message::CancelGenerate),
|
||
]
|
||
.spacing(8),
|
||
);
|
||
container(content).center(Length::Fill).into()
|
||
}
|
||
|
||
fn parse_generation_length(value: &str) -> Result<Option<NonZeroUsize>, String> {
|
||
let length = value
|
||
.trim()
|
||
.parse::<usize>()
|
||
.map_err(|_| "Password length must be a positive whole number.".to_owned())?;
|
||
NonZeroUsize::new(length)
|
||
.map(Some)
|
||
.ok_or_else(|| "Password length must be greater than zero.".to_owned())
|
||
}
|
||
|
||
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 current_unix_seconds() -> Result<u64, String> {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|duration| duration.as_secs())
|
||
.map_err(|_| "the system clock is before the Unix epoch".to_owned())
|
||
}
|
||
|
||
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 key.as_ref() == keyboard::Key::Named(keyboard::key::Named::Escape) {
|
||
return Some(Message::PaletteCancel);
|
||
}
|
||
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},
|
||
git::{GitIdentity, GitRepository},
|
||
repository::{EntryPath, Repository},
|
||
secret_store::{
|
||
SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy,
|
||
SecretReference, SecretStore, SecretStoreError,
|
||
},
|
||
};
|
||
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn desktop_coverage_matrix_contains_every_registered_action() {
|
||
let matrix = include_str!("../../../docs/desktop-audit.md");
|
||
for spec in action::ACTIONS {
|
||
assert!(
|
||
matrix.contains(&format!("`{}`", spec.action.id())),
|
||
"desktop parity matrix is missing {}",
|
||
spec.action.id()
|
||
);
|
||
}
|
||
for required_surface in [
|
||
"Base pass",
|
||
"Pass OTP",
|
||
"Embedded Git",
|
||
"Configuration and lock",
|
||
"macOS",
|
||
"Linux",
|
||
"Windows",
|
||
] {
|
||
assert!(
|
||
matrix.contains(required_surface),
|
||
"missing {required_surface}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn production_desktop_sources_preserve_security_and_architecture_boundaries() {
|
||
let sources = [
|
||
("action.rs", include_str!("action.rs")),
|
||
("editor.rs", include_str!("editor.rs")),
|
||
("folder_picker.rs", include_str!("folder_picker.rs")),
|
||
("main.rs", include_str!("main.rs")),
|
||
("native_menu.rs", include_str!("native_menu.rs")),
|
||
("navigation.rs", include_str!("navigation.rs")),
|
||
("palette.rs", include_str!("palette.rs")),
|
||
];
|
||
for (name, source) in sources {
|
||
let production = source
|
||
.split("#[cfg(test)]\nmod tests")
|
||
.next()
|
||
.unwrap_or(source);
|
||
let forbidden_tokens = [
|
||
["std::", "process"].concat(),
|
||
["process", "::Command"].concat(),
|
||
["Command", "::new("].concat(),
|
||
["Repository", "::open"].concat(),
|
||
["GitRepository", "::"].concat(),
|
||
["OtpUri", "::parse"].concat(),
|
||
["qrcode", "::QrCode"].concat(),
|
||
["fs::", "write"].concat(),
|
||
["File", "::create"].concat(),
|
||
["OpenOptions", "::new"].concat(),
|
||
["println", "!("].concat(),
|
||
["eprintln", "!("].concat(),
|
||
["dbg", "!("].concat(),
|
||
["log", "::info"].concat(),
|
||
["log", "::debug"].concat(),
|
||
["log", "::error"].concat(),
|
||
["tracing", "::info"].concat(),
|
||
["tracing", "::debug"].concat(),
|
||
["tracing", "::error"].concat(),
|
||
["http", "://"].concat(),
|
||
["unsafe", " {"].concat(),
|
||
];
|
||
for forbidden in &forbidden_tokens {
|
||
assert!(
|
||
!production.contains(forbidden),
|
||
"{name} crosses the desktop architecture boundary with {forbidden}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn window_contract_keeps_both_scrollable_panes_usable_at_narrow_size() {
|
||
let settings = window_settings();
|
||
assert_eq!(settings.size, Size::new(960.0, 680.0));
|
||
assert_eq!(settings.min_size, Some(Size::new(480.0, 360.0)));
|
||
let app = test_app(None);
|
||
let _view = app.view();
|
||
}
|
||
|
||
#[test]
|
||
fn utility_panels_share_the_compact_entry_palette() {
|
||
let theme = Theme::Dark;
|
||
assert_eq!(
|
||
entry_area_style(&theme).background,
|
||
Some(Background::Color(ENTRY_AREA_BACKGROUND))
|
||
);
|
||
assert_eq!(
|
||
entry_field_style(&theme, false).background,
|
||
Some(Background::Color(ENTRY_FIELD_BACKGROUND))
|
||
);
|
||
assert_eq!(
|
||
entry_input_style(&theme, text_input::Status::Active).background,
|
||
Background::Color(ENTRY_INPUT_BACKGROUND)
|
||
);
|
||
assert_eq!(
|
||
entry_icon_button(&theme, button::Status::Hovered).background,
|
||
Some(Background::Color(ENTRY_FIELD_ACTIVE_BACKGROUND))
|
||
);
|
||
|
||
let (_temporary, storage) = fixture_storage();
|
||
let mut app = test_app(None);
|
||
app.utility = Some(UtilityView::Settings(SettingsForm::new(&storage)));
|
||
app.storage = Some(storage);
|
||
assert_eq!(
|
||
utility_title(app.utility.as_ref().expect("settings panel")),
|
||
"Settings"
|
||
);
|
||
let _view = app.view();
|
||
}
|
||
|
||
#[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(),
|
||
git_control: None,
|
||
git_progress: None,
|
||
authentication_generation: 0,
|
||
operation_generation: 0,
|
||
tree_generation: 0,
|
||
vault_generation: 0,
|
||
settings_generation: 0,
|
||
workflow_generation: 0,
|
||
otp_generation: 0,
|
||
otp_pending: false,
|
||
selection_after_refresh: None,
|
||
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,
|
||
switching_vault: false,
|
||
confirmation: None,
|
||
after_save: None,
|
||
generation_form: None,
|
||
conflict: false,
|
||
status: String::new(),
|
||
open_menu: None,
|
||
utility: None,
|
||
context_target: None,
|
||
palette: CommandPalette::default(),
|
||
#[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\n first note line\ncustom-field: opaque\n second 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\n first note line\ncustom-field: opaque\n second note line"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn viewer_exposes_every_unlocked_field_and_clears_it_on_lock() {
|
||
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"comments: Recovery codes:\none\ntwo",
|
||
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_eq!(viewer_label(&fields[2]), viewer_label(&fields[3]));
|
||
assert_eq!(fields[4].metadata().kind(), EntryFieldKind::Note);
|
||
assert_eq!(fields[5].metadata().kind(), EntryFieldKind::Note);
|
||
assert_eq!(fields[6].metadata().name(), Some("comments"));
|
||
assert_eq!(fields[6].value(), b"Recovery codes:\none\ntwo");
|
||
assert!(fields[7].metadata().otp().is_some());
|
||
assert_eq!(
|
||
fields[8].metadata().diagnostic(),
|
||
Some(EntryFieldDiagnostic::MalformedOtpUri)
|
||
);
|
||
assert_eq!(
|
||
fields[9].metadata().diagnostic(),
|
||
Some(EntryFieldDiagnostic::NonUtf8Value)
|
||
);
|
||
|
||
let password = fields[0].id();
|
||
assert_eq!(
|
||
viewer_value(&editor.fields()[0]),
|
||
ViewerValue::Text("password")
|
||
);
|
||
assert_eq!(
|
||
viewer_value(&editor.fields()[7]),
|
||
ViewerValue::Text(
|
||
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example"
|
||
)
|
||
);
|
||
assert_eq!(
|
||
viewer_value(&editor.fields()[6]),
|
||
ViewerValue::Text("Recovery codes:\none\ntwo")
|
||
);
|
||
assert_eq!(viewer_value(&editor.fields()[9]), ViewerValue::Unavailable);
|
||
let comments = editor.fields()[6].id();
|
||
editor
|
||
.update_raw(comments, b"comments: Recovery codes:\nalpha\ntwo")
|
||
.expect("update multiline field");
|
||
editor
|
||
.add_value_line_after(comments, 2)
|
||
.expect("append multiline row");
|
||
assert_eq!(editor.fields()[6].value(), b"Recovery codes:\nalpha\ntwo\n");
|
||
assert_eq!(
|
||
editor
|
||
.multiline_content(comments)
|
||
.expect("multiline editor")
|
||
.text(),
|
||
"Recovery codes:\nalpha\ntwo\n"
|
||
);
|
||
editor
|
||
.edit_multiline(
|
||
comments,
|
||
text_editor::Action::Edit(text_editor::Edit::Insert('!')),
|
||
)
|
||
.expect("edit multiline content");
|
||
assert_eq!(
|
||
std::str::from_utf8(editor.fields()[6].value()).expect("UTF-8 field"),
|
||
editor
|
||
.multiline_content(comments)
|
||
.expect("multiline editor")
|
||
.text()
|
||
);
|
||
assert!(document_has_single_totp(editor.document()));
|
||
assert_eq!(
|
||
editor
|
||
.document()
|
||
.display_fields()
|
||
.into_iter()
|
||
.map(EntryField::id)
|
||
.collect::<Vec<_>>(),
|
||
[
|
||
editor.fields()[1].id(),
|
||
editor.fields()[0].id(),
|
||
editor.fields()[7].id(),
|
||
editor.fields()[8].id(),
|
||
editor.fields()[4].id(),
|
||
editor.fields()[5].id(),
|
||
editor.fields()[6].id(),
|
||
editor.fields()[9].id(),
|
||
editor.fields()[2].id(),
|
||
editor.fields()[3].id(),
|
||
]
|
||
);
|
||
editor.navigate(FieldNavigation::First);
|
||
assert_eq!(editor.focused(), Some(editor.fields()[1].id()));
|
||
editor.navigate(FieldNavigation::Next);
|
||
assert_eq!(editor.focused(), Some(password));
|
||
editor.navigate(FieldNavigation::Last);
|
||
assert_eq!(editor.focused(), Some(editor.fields()[3].id()));
|
||
|
||
assert!(!authentication_allows_content(&AuthenticationView::Locked));
|
||
assert!(authentication_allows_content(
|
||
&AuthenticationView::Unlocked(Duration::from_secs(1))
|
||
));
|
||
let mut app = test_app(Some(editor));
|
||
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::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::OpenVault(PathBuf::from("/selected-vault")),
|
||
PendingAction::OpenEntry("other".to_owned()),
|
||
PendingAction::Reload("draft".to_owned()),
|
||
PendingAction::CloseWindow(window::Id::unique()),
|
||
PendingAction::Git(DesktopGitRequest::Pull),
|
||
PendingAction::Git(DesktopGitRequest::Sync),
|
||
PendingAction::Git(DesktopGitRequest::Resolve(Vec::new())),
|
||
] {
|
||
assert!(matches!(
|
||
action,
|
||
PendingAction::OpenVault(_)
|
||
| PendingAction::OpenEntry(_)
|
||
| PendingAction::Reload(_)
|
||
| PendingAction::CloseWindow(_)
|
||
| PendingAction::Git(_)
|
||
));
|
||
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 _task = app.update(Message::FolderPicked(Ok(None)));
|
||
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
|
||
let selected_vault = PathBuf::from("/selected-vault");
|
||
let _task = app.update(Message::FolderPicked(Ok(Some(selected_vault.clone()))));
|
||
assert_eq!(
|
||
app.confirmation,
|
||
Some(PendingAction::OpenVault(selected_vault))
|
||
);
|
||
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
|
||
let _task = app.update(Message::CancelDiscard);
|
||
assert!(app.confirmation.is_none());
|
||
|
||
let _task = app.request_action(PendingAction::Git(DesktopGitRequest::Pull));
|
||
assert_eq!(
|
||
app.confirmation,
|
||
Some(PendingAction::Git(DesktopGitRequest::Pull))
|
||
);
|
||
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
|
||
let _task = app.update(Message::CancelDiscard);
|
||
|
||
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));
|
||
|
||
app.confirmation = None;
|
||
app.switching_vault = true;
|
||
let _task = app.update(Message::RequestClose(id));
|
||
assert!(app.confirmation.is_none());
|
||
assert!(app.status.contains("Wait for vault validation"));
|
||
}
|
||
|
||
#[test]
|
||
fn command_palette_focus_matching_execution_cancellation_and_availability_are_shared() {
|
||
let mut app = test_app(None);
|
||
app.pane_focus = PaneFocus::Content;
|
||
|
||
let _focus = app.update(Message::Action(UiAction::CommandPalette));
|
||
assert!(app.palette.is_open());
|
||
assert_eq!(app.palette.selected(), 0);
|
||
let _task = app.update(Message::SidebarNavigate(NavigationKey::Next));
|
||
assert_eq!(app.palette.selected(), 1);
|
||
let _task = app.update(Message::SidebarNavigate(NavigationKey::Previous));
|
||
assert_eq!(app.palette.selected(), 0);
|
||
|
||
let _task = app.update(Message::PaletteQueryChanged("about".to_owned()));
|
||
assert_eq!(app.palette.selected_action(), Some(UiAction::About));
|
||
let _task = app.update(Message::SidebarNavigate(NavigationKey::Activate));
|
||
assert!(!app.palette.is_open());
|
||
assert!(matches!(app.utility, Some(UtilityView::About)));
|
||
assert_eq!(app.pane_focus, PaneFocus::Content);
|
||
|
||
app.utility = None;
|
||
let _task = app.update(Message::Action(UiAction::CommandPalette));
|
||
let _task = app.update(Message::PaletteQueryChanged("lock".to_owned()));
|
||
assert_eq!(app.palette.selected_action(), Some(UiAction::Lock));
|
||
let _task = app.update(Message::SidebarNavigate(NavigationKey::Activate));
|
||
assert!(app.palette.is_open());
|
||
assert!(app.status.contains("already locked"));
|
||
let _restore = app.update(Message::PaletteCancel);
|
||
assert!(!app.palette.is_open());
|
||
assert!(app.palette.query().is_empty());
|
||
assert_eq!(app.pane_focus, PaneFocus::Content);
|
||
|
||
app.confirmation = Some(PendingAction::CloseWindow(window::Id::unique()));
|
||
let _task = app.update(Message::Action(UiAction::CommandPalette));
|
||
assert!(!app.palette.is_open());
|
||
assert!(app.status.contains("Finish the current confirmation"));
|
||
app.confirmation = None;
|
||
|
||
let (_temporary, storage) = fixture_storage();
|
||
app.storage = Some(storage);
|
||
let _task = app.update(Message::Action(UiAction::CommandPalette));
|
||
let _task = app.update(Message::PaletteInvoke(UiAction::Settings));
|
||
assert!(!app.palette.is_open());
|
||
assert!(matches!(app.utility, Some(UtilityView::Settings(_))));
|
||
|
||
let escape = Event::Keyboard(keyboard::Event::KeyPressed {
|
||
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||
location: keyboard::Location::Standard,
|
||
modifiers: keyboard::Modifiers::NONE,
|
||
text: None,
|
||
repeat: false,
|
||
});
|
||
assert!(matches!(
|
||
event_message(&escape),
|
||
Some(Message::PaletteCancel)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn settings_form_validates_and_storage_persists_atomically() {
|
||
let (_temporary, storage) = fixture_storage();
|
||
let before = fs::read(storage.config_source()).expect("original config");
|
||
let mut form = SettingsForm::new(&storage);
|
||
assert_eq!(form.vault, storage.vault().display().to_string());
|
||
assert_eq!(form.default_key, storage.default_key());
|
||
|
||
form.authentication_timeout = "not-a-number".to_owned();
|
||
assert!(form.settings(&storage).is_err());
|
||
form.authentication_timeout = "600".to_owned();
|
||
form.default_key = "missing-key".to_owned();
|
||
let invalid = form.settings(&storage).expect("syntactically valid form");
|
||
assert!(storage.update_settings(invalid).is_err());
|
||
assert_eq!(
|
||
fs::read(storage.config_source()).expect("rejected config"),
|
||
before
|
||
);
|
||
|
||
form.default_key = storage.default_key().to_owned();
|
||
let updated = storage
|
||
.update_settings(form.settings(&storage).expect("valid form"))
|
||
.expect("persisted settings");
|
||
assert_eq!(
|
||
updated.authentication_timeout().duration(),
|
||
Duration::from_secs(600)
|
||
);
|
||
let reloaded = DesktopStorage::load(Some(storage.config_source())).expect("reload config");
|
||
assert_eq!(
|
||
reloaded.authentication_timeout().duration(),
|
||
Duration::from_secs(600)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn vault_switch_result_replaces_state_only_after_storage_success() {
|
||
let (_temporary, storage) = fixture_storage();
|
||
let mut failed = test_app(None);
|
||
failed.storage = Some(storage.clone());
|
||
failed.switching_vault = true;
|
||
failed.vault_generation = 4;
|
||
let original = failed.storage.as_ref().expect("storage").vault().to_owned();
|
||
let _task = failed.update(Message::VaultSwitched {
|
||
generation: 4,
|
||
result: Box::new(Err("injected failure".to_owned())),
|
||
});
|
||
assert_eq!(failed.storage.as_ref().expect("storage").vault(), original);
|
||
assert!(!failed.switching_vault);
|
||
assert!(failed.status.contains("Current vault unchanged"));
|
||
|
||
let mut editor = empty_editor(&storage, "draft");
|
||
editor.add_after(None).expect("dirty line");
|
||
let mut succeeded = test_app(Some(editor));
|
||
succeeded.authentication = AuthenticationView::Unlocked(Duration::from_secs(60));
|
||
succeeded.switching_vault = true;
|
||
succeeded.vault_generation = 5;
|
||
succeeded.operation_generation = 8;
|
||
let _task = succeeded.update(Message::VaultSwitched {
|
||
generation: 5,
|
||
result: Box::new(Ok(storage)),
|
||
});
|
||
assert!(succeeded.editor.is_none());
|
||
assert!(matches!(
|
||
succeeded.authentication,
|
||
AuthenticationView::Locked
|
||
));
|
||
assert_eq!(succeeded.tree_state, TreeState::Loading);
|
||
assert!(!succeeded.switching_vault);
|
||
assert_eq!(succeeded.operation_generation, 9);
|
||
}
|
||
|
||
#[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(Duration::from_secs(45));
|
||
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,
|
||
..SensitiveUiState::default()
|
||
};
|
||
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 storage_rfc_totp_countdown_does_not_renew_authentication_and_lock_clears_presentations() {
|
||
let (_temporary, storage) = fixture_storage();
|
||
let mut provider = FixtureSecrets;
|
||
let mut document = storage
|
||
.create_document("otp/rfc6238", &mut provider)
|
||
.expect("create OTP document");
|
||
document
|
||
.add(
|
||
0,
|
||
ironstorage::document::EntryFieldDraft::line(b"password".to_vec())
|
||
.expect("password"),
|
||
)
|
||
.expect("password field");
|
||
document
|
||
.add(
|
||
1,
|
||
ironstorage::document::EntryFieldDraft::otp_uri(
|
||
b"otpauth://totp/RFC6238?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=8&period=30".to_vec(),
|
||
)
|
||
.expect("RFC URI"),
|
||
)
|
||
.expect("OTP field");
|
||
storage.save_document(&document).expect("save OTP fixture");
|
||
let outcome = storage
|
||
.otp_code("otp/rfc6238", 59, false, &mut provider)
|
||
.expect("desktop OTP outcome");
|
||
let (code, validity, metadata, _, _) = outcome.into_parts();
|
||
assert_eq!(code.expose(), b"94287082");
|
||
let display = OtpDisplay {
|
||
entry: "otp/rfc6238".to_owned(),
|
||
code,
|
||
validity,
|
||
metadata,
|
||
observed_at: 59,
|
||
};
|
||
assert_eq!(display.remaining_at(59), Some(1));
|
||
assert_eq!(display.remaining_at(60), Some(0));
|
||
assert_eq!(otp_progress(&display), Some((1, 30)));
|
||
assert_eq!(otp_code_text(&display), "9428 7082");
|
||
|
||
let mut hotp_document = storage
|
||
.create_document("otp/rfc4226", &mut provider)
|
||
.expect("create HOTP document");
|
||
hotp_document
|
||
.add(
|
||
0,
|
||
ironstorage::document::EntryFieldDraft::line(b"password".to_vec())
|
||
.expect("password"),
|
||
)
|
||
.expect("password field");
|
||
hotp_document
|
||
.add(
|
||
1,
|
||
ironstorage::document::EntryFieldDraft::otp_uri(
|
||
b"otpauth://hotp/RFC4226?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0"
|
||
.to_vec(),
|
||
)
|
||
.expect("RFC HOTP URI"),
|
||
)
|
||
.expect("HOTP field");
|
||
storage
|
||
.save_document(&hotp_document)
|
||
.expect("save HOTP fixture");
|
||
let hotp = storage
|
||
.otp_code("otp/rfc4226", 59, true, &mut provider)
|
||
.expect("HOTP outcome");
|
||
let mut app = test_app(None);
|
||
app.otp_generation = 1;
|
||
app.utility = Some(UtilityView::Otp(OtpForm::new("otp/rfc4226".to_owned())));
|
||
let _task = app.update(Message::OtpFinished {
|
||
generation: 1,
|
||
completion: Arc::new(Mutex::new(Some(Ok(OtpTaskResult::Code {
|
||
entry: "otp/rfc4226".to_owned(),
|
||
copy: false,
|
||
observed_at: 59,
|
||
outcome: hotp,
|
||
})))),
|
||
});
|
||
assert_eq!(
|
||
app.sensitive
|
||
.otp
|
||
.as_ref()
|
||
.expect("HOTP displayed")
|
||
.code
|
||
.expose(),
|
||
b"287082"
|
||
);
|
||
assert!(app.status.contains("counter 1"));
|
||
let refreshed = app.editor.as_ref().expect("refreshed HOTP entry");
|
||
assert_eq!(refreshed.entry(), "otp/rfc4226");
|
||
let refreshed_otp = refreshed
|
||
.fields()
|
||
.iter()
|
||
.find_map(|field| field.metadata().otp())
|
||
.expect("refreshed HOTP metadata");
|
||
assert_eq!(refreshed_otp.kind(), OtpKind::Hotp);
|
||
assert_eq!(refreshed_otp.counter(), Some(1));
|
||
|
||
let timeout = Duration::from_secs(2);
|
||
let (session, key, clock) = session(timeout);
|
||
let mut handle = Some(session.authenticate(&key).expect("authenticate"));
|
||
let mut sensitive = SensitiveUiState {
|
||
otp: Some(display),
|
||
otp_uri: Some(SecretBytes::new(b"otpauth://secret".to_vec())),
|
||
otp_qr: Some(
|
||
QrMatrix::encode(&SecretBytes::new(b"otpauth://secret".to_vec())).expect("QR"),
|
||
),
|
||
..SensitiveUiState::default()
|
||
};
|
||
clock.advance(Duration::from_secs(1));
|
||
assert_eq!(
|
||
poll_lease(&session, &mut handle, &mut sensitive).expect("countdown poll"),
|
||
LeasePoll::Active(Duration::from_secs(1))
|
||
);
|
||
sensitive.otp.as_mut().expect("OTP display").observed_at = 60;
|
||
clock.advance(Duration::from_secs(1));
|
||
assert_eq!(
|
||
poll_lease(&session, &mut handle, &mut sensitive).expect("expiry poll"),
|
||
LeasePoll::Expired
|
||
);
|
||
assert!(handle.is_none());
|
||
assert!(sensitive.otp.is_none());
|
||
assert!(sensitive.otp_uri.is_none());
|
||
assert!(sensitive.otp_qr.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());
|
||
}
|
||
|
||
#[test]
|
||
fn recipient_forms_round_trip_storage_keys_and_create_upstream_policies() {
|
||
let (_temporary, storage) = fixture_storage();
|
||
let mut root = RecipientForm::new(
|
||
RecipientWorkflowKind::InitializeStore,
|
||
&storage,
|
||
String::new(),
|
||
)
|
||
.expect("root initialization form");
|
||
assert!(root.recipients.len() >= 2);
|
||
let replacement_default = root
|
||
.recipients
|
||
.iter_mut()
|
||
.find(|recipient| recipient.can_default && !recipient.default)
|
||
.expect("second secret encryption key");
|
||
replacement_default.selected = true;
|
||
root.default_key
|
||
.clone_from(&replacement_default.fingerprint);
|
||
root.confirmed = true;
|
||
let root_request = root.request().expect("root initialization request");
|
||
fs::remove_file(storage.vault().join(".gpg-id")).expect("empty uninitialized store");
|
||
let repository = Repository::open(storage.vault()).expect("repository");
|
||
let git = GitRepository::init(&repository, GitIdentity::ironstorage()).expect("Git init");
|
||
let commits_before = git.log(None).expect("initial log").len();
|
||
let (initialized, root_outcome) = storage
|
||
.apply_recipient_policy(&root_request, &root.default_key, &mut FixtureSecrets)
|
||
.expect("root initialization");
|
||
assert!(root_outcome.directory().as_path().as_os_str().is_empty());
|
||
let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("reopen Git");
|
||
assert_eq!(
|
||
git.log(None).expect("root init log").len(),
|
||
commits_before + 1
|
||
);
|
||
assert!(git.status().expect("root init status").is_clean());
|
||
assert_eq!(
|
||
fs::read(storage.vault().join(".gpg-id")).expect("root policy bytes"),
|
||
root_request
|
||
.key_identities
|
||
.iter()
|
||
.map(|identity| format!("{identity}\n"))
|
||
.collect::<String>()
|
||
.as_bytes()
|
||
);
|
||
assert_eq!(initialized.default_key(), root.default_key);
|
||
assert_eq!(
|
||
DesktopStorage::load(Some(storage.config_source()))
|
||
.expect("reloaded config")
|
||
.default_key(),
|
||
root.default_key
|
||
);
|
||
|
||
let mut form = RecipientForm::new(
|
||
RecipientWorkflowKind::NewFolder,
|
||
&initialized,
|
||
"team/services".to_owned(),
|
||
)
|
||
.expect("recipient form");
|
||
assert!(form.recipients.len() >= 2);
|
||
let default = form
|
||
.recipients
|
||
.iter()
|
||
.find(|recipient| recipient.default)
|
||
.expect("configured default key");
|
||
assert!(default.selected);
|
||
assert!(form.request().is_err(), "replacement requires confirmation");
|
||
form.confirmed = true;
|
||
let request = form.request().expect("validated request");
|
||
let (updated, outcome) = initialized
|
||
.apply_recipient_policy(&request, &form.default_key, &mut FixtureSecrets)
|
||
.expect("nested recipient policy");
|
||
assert_eq!(outcome.directory().as_path(), Path::new("team/services"));
|
||
assert_eq!(outcome.recipients().len(), 1);
|
||
assert_eq!(updated.default_key(), form.default_key);
|
||
assert_eq!(
|
||
DesktopStorage::load(Some(initialized.config_source()))
|
||
.expect("reloaded config")
|
||
.default_key(),
|
||
form.default_key
|
||
);
|
||
assert_eq!(
|
||
fs::read(storage.vault().join("team/services/.gpg-id")).expect("policy bytes"),
|
||
request
|
||
.key_identities
|
||
.iter()
|
||
.map(|identity| format!("{identity}\n"))
|
||
.collect::<String>()
|
||
.as_bytes()
|
||
);
|
||
|
||
form.path = "../escape".to_owned();
|
||
let invalid = form.request().expect("UI leaves path rules to storage");
|
||
assert!(
|
||
initialized
|
||
.apply_recipient_policy(&invalid, &form.default_key, &mut FixtureSecrets)
|
||
.is_err()
|
||
);
|
||
assert!(!storage.vault().join("escape").exists());
|
||
}
|
||
|
||
#[test]
|
||
fn new_entry_drafts_generate_before_save_and_reject_collisions() {
|
||
let (_temporary, storage) = fixture_storage();
|
||
let form = NewEntryForm {
|
||
path: "generated/account".to_owned(),
|
||
generate: true,
|
||
length: "32".to_owned(),
|
||
no_symbols: true,
|
||
running: false,
|
||
error: None,
|
||
};
|
||
let password = form.password().expect("generated password");
|
||
assert_eq!(password.expose().len(), 32);
|
||
assert!(password.expose().iter().all(u8::is_ascii_alphanumeric));
|
||
let document = storage
|
||
.create_document(&form.path, &mut FixtureSecrets)
|
||
.expect("new document");
|
||
assert!(document.fields().is_empty());
|
||
assert!(!storage.vault().join("generated/account.gpg").exists());
|
||
|
||
let editor = EntryEditor::new_entry(document, password).expect("new entry editor");
|
||
assert!(editor.is_dirty());
|
||
assert_eq!(editor.fields().len(), 1);
|
||
assert_eq!(
|
||
editor.fields()[0].metadata().kind(),
|
||
EntryFieldKind::Password
|
||
);
|
||
save_document(&storage, &editor).expect("save generated entry");
|
||
assert!(storage.vault().join("generated/account.gpg").is_file());
|
||
let collision = storage
|
||
.create_document(&form.path, &mut FixtureSecrets)
|
||
.expect_err("existing entry must not become a draft");
|
||
assert_eq!(collision.kind(), DesktopErrorKind::EntryExists);
|
||
}
|
||
|
||
#[test]
|
||
fn generated_replacements_confirm_and_update_only_the_selected_draft_field() {
|
||
let (_temporary, storage) = fixture_storage();
|
||
let mut editor = empty_editor(&storage, "replacement");
|
||
editor.add_after(None).expect("password field");
|
||
let password = editor.fields()[0].id();
|
||
editor.update_raw(password, b"old password").expect("value");
|
||
editor.add_after(Some(password)).expect("username field");
|
||
let username = editor.fields()[1].id();
|
||
editor
|
||
.update_raw(username, b"username: alice")
|
||
.expect("username");
|
||
let username_before = editor.fields()[1].contents().expose().to_vec();
|
||
let mut app = test_app(Some(editor));
|
||
app.authentication = AuthenticationView::Unlocked(Duration::from_secs(60));
|
||
app.content_mode = ContentMode::Editor;
|
||
|
||
let _task = app.update(Message::FieldAction(password, UiAction::GeneratePassword));
|
||
assert!(
|
||
app.generation_form
|
||
.as_ref()
|
||
.is_some_and(|form| form.replacement)
|
||
);
|
||
let _task = app.update(Message::SubmitGenerate);
|
||
assert!(
|
||
app.generation_form
|
||
.as_ref()
|
||
.is_some_and(|form| form.error.is_some())
|
||
);
|
||
assert_eq!(
|
||
app.editor.as_ref().expect("draft").fields()[0].value(),
|
||
b"old password"
|
||
);
|
||
|
||
let _task = app.update(Message::GenerateLengthChanged("12".to_owned()));
|
||
let _task = app.update(Message::ToggleGenerateSymbols);
|
||
let _task = app.update(Message::ToggleGenerateConfirmation);
|
||
let _task = app.update(Message::SubmitGenerate);
|
||
let editor = app.editor.as_ref().expect("updated draft");
|
||
assert!(app.generation_form.is_none());
|
||
assert_eq!(editor.fields()[0].value().len(), 12);
|
||
assert!(
|
||
editor.fields()[0]
|
||
.value()
|
||
.iter()
|
||
.all(u8::is_ascii_alphanumeric)
|
||
);
|
||
assert_eq!(
|
||
editor
|
||
.document()
|
||
.field(username)
|
||
.unwrap()
|
||
.contents()
|
||
.expose(),
|
||
username_before
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn creation_and_generation_state_preserve_drafts_on_cancel_or_failure() {
|
||
let (_temporary, storage) = fixture_storage();
|
||
let mut existing = empty_editor(&storage, "existing");
|
||
existing.add_after(None).expect("existing draft field");
|
||
let field_id = existing.fields()[0].id();
|
||
let mut app = test_app(Some(existing));
|
||
app.storage = Some(storage.clone());
|
||
app.utility = Some(UtilityView::NewEntry(NewEntryForm::new(
|
||
"cancelled".to_owned(),
|
||
)));
|
||
let _task = app.update(Message::DismissUtility);
|
||
assert!(app.utility.is_none());
|
||
assert!(app.editor.is_some());
|
||
assert!(!storage.vault().join("cancelled.gpg").exists());
|
||
|
||
let policy_before = fs::read(storage.vault().join(".gpg-id")).expect("policy before");
|
||
app.utility = Some(UtilityView::Recipients(
|
||
RecipientForm::new(
|
||
RecipientWorkflowKind::InitializeStore,
|
||
&storage,
|
||
String::new(),
|
||
)
|
||
.expect("recipient form"),
|
||
));
|
||
let _task = app.update(Message::DismissUtility);
|
||
assert!(app.editor.is_some());
|
||
assert_eq!(
|
||
fs::read(storage.vault().join(".gpg-id")).expect("policy after cancel"),
|
||
policy_before
|
||
);
|
||
|
||
app.utility = Some(UtilityView::NewEntry(NewEntryForm::new(
|
||
"failed".to_owned(),
|
||
)));
|
||
app.workflow_generation = 4;
|
||
let completion = Arc::new(Mutex::new(Some((
|
||
SecretBytes::new(Vec::new()),
|
||
Err("injected creation failure".to_owned()),
|
||
))));
|
||
let _task = app.update(Message::CreateFinished {
|
||
generation: 4,
|
||
completion,
|
||
});
|
||
assert!(app.editor.is_some());
|
||
assert!(matches!(
|
||
app.utility,
|
||
Some(UtilityView::NewEntry(NewEntryForm { error: Some(_), .. }))
|
||
));
|
||
|
||
let mut generate = GenerateForm::new(field_id, true);
|
||
assert!(generate.generate().is_err());
|
||
generate.confirmed = true;
|
||
generate.length = "0".to_owned();
|
||
assert!(generate.generate().is_err());
|
||
generate.length = "16".to_owned();
|
||
assert_eq!(generate.generate().expect("replacement").expose().len(), 16);
|
||
}
|
||
|
||
#[test]
|
||
fn desktop_search_and_mutations_use_typed_storage_contracts_and_clean_git_commits() {
|
||
let (_temporary, storage) = fixture_storage();
|
||
let repository = Repository::open(storage.vault()).expect("repository");
|
||
GitRepository::init(&repository, GitIdentity::ironstorage()).expect("Git init");
|
||
|
||
let mut source = empty_editor(&storage, "team/alpha");
|
||
source.add_after(None).expect("password field");
|
||
let password = source.fields()[0].id();
|
||
source
|
||
.update_raw(password, b"needle-secret")
|
||
.expect("password value");
|
||
save_document(&storage, &source).expect("source save");
|
||
let commits_before_mutations = GitRepository::open(&repository, GitIdentity::ironstorage())
|
||
.expect("Git after source save")
|
||
.log(None)
|
||
.expect("initial Git log")
|
||
.len();
|
||
|
||
let names = storage
|
||
.find(&FindRequest {
|
||
terms: vec!["alpha".to_owned()],
|
||
})
|
||
.expect("name search");
|
||
assert_eq!(names.matches().len(), 1);
|
||
assert_eq!(
|
||
names.matches()[0].id(),
|
||
&TreeNodeId::Entry(EntryPath::parse("team/alpha").expect("entry identity"))
|
||
);
|
||
|
||
let contents = storage
|
||
.grep(
|
||
&GrepRequest {
|
||
pattern: "needle-secret".to_owned(),
|
||
ignore_case: false,
|
||
invert_match: false,
|
||
line_number: true,
|
||
fixed_strings: true,
|
||
},
|
||
&mut FixtureSecrets,
|
||
)
|
||
.expect("decrypted search");
|
||
assert_eq!(contents.entries().len(), 1);
|
||
assert_eq!(contents.entries()[0].path().to_string(), "team/alpha");
|
||
assert_eq!(contents.entries()[0].lines()[0].number(), 1);
|
||
assert_eq!(
|
||
contents.entries()[0].lines()[0].contents().expose(),
|
||
b"needle-secret"
|
||
);
|
||
|
||
let copied = storage
|
||
.mutate(
|
||
&DesktopMutationRequest::Copy(CopyRequest {
|
||
source: "team/alpha".to_owned(),
|
||
destination: "team/beta".to_owned(),
|
||
force: false,
|
||
}),
|
||
OverwriteDecision::Decline,
|
||
&mut FixtureSecrets,
|
||
)
|
||
.expect("copy");
|
||
assert_eq!(copied.action(), MutationAction::Copy);
|
||
assert_eq!(
|
||
copied.selection(),
|
||
Some(&MutationSelection::Entry(
|
||
EntryPath::parse("team/beta").expect("copy selection")
|
||
))
|
||
);
|
||
assert!(
|
||
storage
|
||
.mutate(
|
||
&DesktopMutationRequest::Copy(CopyRequest {
|
||
source: "team/alpha".to_owned(),
|
||
destination: "team/beta".to_owned(),
|
||
force: false,
|
||
}),
|
||
OverwriteDecision::Decline,
|
||
&mut FixtureSecrets,
|
||
)
|
||
.is_err()
|
||
);
|
||
|
||
let moved = storage
|
||
.mutate(
|
||
&DesktopMutationRequest::Move(MoveRequest {
|
||
source: "team/beta".to_owned(),
|
||
destination: "team/gamma".to_owned(),
|
||
force: false,
|
||
}),
|
||
OverwriteDecision::Decline,
|
||
&mut FixtureSecrets,
|
||
)
|
||
.expect("move");
|
||
assert_eq!(moved.action(), MutationAction::Move);
|
||
storage
|
||
.mutate(
|
||
&DesktopMutationRequest::Remove(RemoveRequest {
|
||
entry: "team/alpha".to_owned(),
|
||
recursive: false,
|
||
force: false,
|
||
}),
|
||
OverwriteDecision::Allow,
|
||
&mut FixtureSecrets,
|
||
)
|
||
.expect("remove");
|
||
assert!(!storage.vault().join("team/alpha.gpg").exists());
|
||
assert!(!storage.vault().join("team/beta.gpg").exists());
|
||
assert_eq!(
|
||
storage
|
||
.open_document("team/gamma", &mut FixtureSecrets)
|
||
.expect("moved document")
|
||
.serialize()
|
||
.expose(),
|
||
b"needle-secret"
|
||
);
|
||
let git = GitRepository::open(&repository, GitIdentity::ironstorage()).expect("Git reopen");
|
||
assert!(git.status().expect("Git status").is_clean());
|
||
assert_eq!(
|
||
git.log(None).expect("Git log").len(),
|
||
commits_before_mutations + 3
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn desktop_git_status_is_typed_https_only_and_cancellation_safe() {
|
||
let (temporary, _initial) = fixture_storage();
|
||
let config_path = temporary.path().join("config.toml");
|
||
let mut config = fs::read_to_string(&config_path).expect("configuration");
|
||
config.push_str(
|
||
"[[git.remotes]]\nname = \"origin\"\nurl = \"https://example.test/store.git\"\nserver_id = \"server\"\napplication_id = \"application\"\n",
|
||
);
|
||
fs::write(&config_path, config).expect("configured remote");
|
||
let storage = DesktopStorage::load(Some(&config_path)).expect("reload remote");
|
||
let repository = Repository::open(storage.vault()).expect("repository");
|
||
let mut git = GitRepository::init(&repository, GitIdentity::ironstorage())
|
||
.expect("initialize embedded Git");
|
||
git.add_remote("origin", "https://example.test/store.git")
|
||
.expect("HTTPS remote");
|
||
|
||
let phases = Arc::new(Mutex::new(Vec::new()));
|
||
let reported = Arc::clone(&phases);
|
||
let control = GitOperationControl::new(move |phase| {
|
||
reported.lock().expect("progress").push(phase);
|
||
});
|
||
let result = storage
|
||
.git_operation(None, &DesktopGitRequest::Refresh, &control)
|
||
.expect("status refresh");
|
||
assert_eq!(result.outcome(), &DesktopGitOutcome::Refreshed);
|
||
assert_eq!(result.snapshot().branch(), "main");
|
||
assert!(result.snapshot().status().is_clean());
|
||
assert_eq!(
|
||
result.snapshot().remote().expect("remote").url(),
|
||
"https://example.test/store.git"
|
||
);
|
||
assert_eq!(
|
||
*phases.lock().expect("progress"),
|
||
[GitProgressPhase::Validating]
|
||
);
|
||
|
||
let cancelled = GitOperationControl::default();
|
||
cancelled.cancel();
|
||
let error = storage
|
||
.git_operation(None, &DesktopGitRequest::Refresh, &cancelled)
|
||
.expect_err("cancel before repository access");
|
||
assert_eq!(error.kind(), DesktopErrorKind::Git);
|
||
assert_eq!(error.to_string(), "the Git operation was cancelled");
|
||
assert_eq!(error.git_error(), Some(&GitError::Cancelled));
|
||
assert_eq!(
|
||
git_failure_message(&error),
|
||
"The Git operation was cancelled safely."
|
||
);
|
||
|
||
let git_config = storage.vault().join(".git/config");
|
||
let invalid = fs::read_to_string(&git_config)
|
||
.expect("Git config")
|
||
.replace(
|
||
"https://example.test/store.git",
|
||
"ssh://example.test/store.git",
|
||
);
|
||
fs::write(git_config, invalid).expect("hostile remote fixture");
|
||
let error = storage
|
||
.git_operation(
|
||
None,
|
||
&DesktopGitRequest::Refresh,
|
||
&GitOperationControl::default(),
|
||
)
|
||
.expect_err("non-HTTPS remote rejection");
|
||
assert_eq!(error.kind(), DesktopErrorKind::Git);
|
||
assert_eq!(error.git_error(), Some(&GitError::ForbiddenRemoteUrl));
|
||
assert_eq!(
|
||
error.to_string(),
|
||
"Git remotes must use credential-free HTTPS URLs"
|
||
);
|
||
|
||
assert!(DesktopGitRequest::Pull.requires_authentication());
|
||
assert!(DesktopGitRequest::Pull.changes_worktree());
|
||
assert!(!DesktopGitRequest::Push.changes_worktree());
|
||
}
|
||
|
||
#[test]
|
||
fn search_and_mutation_forms_preserve_dirty_state_on_cancel_failure_and_lock() {
|
||
let (_temporary, storage) = fixture_storage();
|
||
let mut editor = empty_editor(&storage, "draft");
|
||
editor.add_after(None).expect("dirty field");
|
||
let draft = editor.document().serialize().expose().to_vec();
|
||
let draft_id = TreeNodeId::Entry(EntryPath::parse("draft").expect("draft identity"));
|
||
let mut app = test_app(Some(editor));
|
||
app.storage = Some(storage.clone());
|
||
app.navigation
|
||
.replace_test_nodes(vec![navigation::TestNode {
|
||
id: draft_id.clone(),
|
||
name: "draft".to_owned(),
|
||
children: Vec::new(),
|
||
}]);
|
||
assert!(app.navigation.select_id(&draft_id));
|
||
|
||
let mut delete = MutationForm::new(MutationKind::Delete, draft_id.clone());
|
||
assert!(delete.request().is_err());
|
||
delete.confirmed = true;
|
||
assert!(matches!(
|
||
delete.request(),
|
||
Ok(DesktopMutationRequest::Remove(RemoveRequest {
|
||
recursive: false,
|
||
..
|
||
}))
|
||
));
|
||
app.utility = Some(UtilityView::Mutation(delete));
|
||
let _task = app.update(Message::SubmitMutation);
|
||
assert!(matches!(app.confirmation, Some(PendingAction::Mutate(_))));
|
||
let _task = app.update(Message::CancelDiscard);
|
||
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
|
||
assert_eq!(
|
||
app.editor
|
||
.as_ref()
|
||
.expect("dirty editor")
|
||
.document()
|
||
.serialize()
|
||
.expose(),
|
||
draft
|
||
);
|
||
|
||
app.workflow_generation = 9;
|
||
let _task = app.update(Message::MutationFinished {
|
||
generation: 9,
|
||
result: Box::new(Err("injected mutation failure".to_owned())),
|
||
});
|
||
assert_eq!(app.navigation.selected(), Some(&draft_id));
|
||
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
|
||
assert!(matches!(
|
||
app.utility,
|
||
Some(UtilityView::Mutation(MutationForm { error: Some(_), .. }))
|
||
));
|
||
let _task = app.update(Message::DismissUtility);
|
||
assert!(app.utility.is_none());
|
||
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
|
||
|
||
let mut search = SearchForm::new(SearchMode::Contents);
|
||
search.query = "needle".to_owned();
|
||
search.ignore_case = true;
|
||
search.invert_match = true;
|
||
search.line_numbers = false;
|
||
search.fixed_strings = true;
|
||
assert_eq!(
|
||
search.request().expect("content request"),
|
||
SearchRequest::Contents(GrepRequest {
|
||
pattern: "needle".to_owned(),
|
||
ignore_case: true,
|
||
invert_match: true,
|
||
line_number: false,
|
||
fixed_strings: true,
|
||
})
|
||
);
|
||
let mut names = SearchForm::new(SearchMode::Names);
|
||
names.terms.push("first term".to_owned());
|
||
names.query = "second term".to_owned();
|
||
assert_eq!(
|
||
names.request().expect("name request"),
|
||
SearchRequest::Names(FindRequest {
|
||
terms: vec!["first term".to_owned(), "second term".to_owned()],
|
||
})
|
||
);
|
||
search.results = Some(SearchResults::Contents(
|
||
storage
|
||
.grep(
|
||
&GrepRequest {
|
||
pattern: ".*".to_owned(),
|
||
ignore_case: false,
|
||
invert_match: false,
|
||
line_number: true,
|
||
fixed_strings: false,
|
||
},
|
||
&mut FixtureSecrets,
|
||
)
|
||
.expect("search results"),
|
||
));
|
||
app.utility = Some(UtilityView::Search(search));
|
||
app.authentication_lost("locked".to_owned());
|
||
assert!(matches!(
|
||
app.utility,
|
||
Some(UtilityView::Search(SearchForm {
|
||
results: None,
|
||
error: Some(_),
|
||
..
|
||
}))
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn kdbx_form_builds_a_confirmed_storage_request_and_is_cleared_on_lock() {
|
||
let mut form = KdbxForm {
|
||
source: "/tmp/passwords.kdbx".to_owned(),
|
||
key_file: "/tmp/passwords.key".to_owned(),
|
||
password: Zeroizing::new("database password".to_owned()),
|
||
quick_add: true,
|
||
confirmed: true,
|
||
..KdbxForm::default()
|
||
};
|
||
let (request, password) = form.request().expect("confirmed request");
|
||
assert_eq!(
|
||
request.source(),
|
||
std::path::Path::new("/tmp/passwords.kdbx")
|
||
);
|
||
assert_eq!(
|
||
request.key_file(),
|
||
Some(std::path::Path::new("/tmp/passwords.key"))
|
||
);
|
||
assert_eq!(request.mode(), KdbxImportMode::QuickAdd);
|
||
assert_eq!(password.expose(), b"database password");
|
||
assert!(!format!("{form:?}").contains("database password"));
|
||
|
||
form.running = true;
|
||
let mut app = App::new().0;
|
||
app.utility = Some(UtilityView::Kdbx(form));
|
||
app.authentication_lost("locked".to_owned());
|
||
let Some(UtilityView::Kdbx(form)) = app.utility else {
|
||
panic!("expected KDBX form");
|
||
};
|
||
assert!(form.password.is_empty());
|
||
assert!(!form.confirmed);
|
||
assert!(!form.running);
|
||
}
|
||
}
|