Fix unlocked TUI visibility and clipboard feedback
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! Pure UI state machine. Storage behavior is represented only by typed results.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::{collections::BTreeSet, time::Instant};
|
||||
|
||||
use ironstorage::{
|
||||
command::{
|
||||
@@ -12,7 +12,7 @@ use ironstorage::{
|
||||
document::{DocumentError, EntryDocument, EntryFieldId},
|
||||
git::{GitConflict, GitProgressPhase, GitSnapshot},
|
||||
otp::OtpCodeValidity,
|
||||
presentation::{ClipboardDisposition, QrMatrix},
|
||||
presentation::{ClipboardDisposition, ClipboardError, QrMatrix},
|
||||
read::{FindResults, GrepResults, TreeModel},
|
||||
repository::SecretBytes,
|
||||
write::WriteOutcome,
|
||||
@@ -64,6 +64,15 @@ pub struct RequestToken {
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ClipboardPresentationId(pub(crate) u64);
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ClipboardPresentation {
|
||||
id: ClipboardPresentationId,
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct StartupData {
|
||||
pub config: Config,
|
||||
@@ -188,7 +197,14 @@ pub enum AsyncPayload {
|
||||
entry: String,
|
||||
document: Box<EntryDocument>,
|
||||
},
|
||||
ClipboardFinished(ClipboardDisposition),
|
||||
ClipboardStarted {
|
||||
presentation: ClipboardPresentationId,
|
||||
deadline: Instant,
|
||||
},
|
||||
ClipboardFinished {
|
||||
presentation: ClipboardPresentationId,
|
||||
result: Result<ClipboardDisposition, ClipboardError>,
|
||||
},
|
||||
GeneratedField {
|
||||
target: EntryFieldId,
|
||||
password: SecretBytes,
|
||||
@@ -273,7 +289,10 @@ pub enum AppEffect {
|
||||
None,
|
||||
RefreshTree,
|
||||
AuthenticateEntry(String),
|
||||
CopyFocused(SecretBytes),
|
||||
CopyFocused {
|
||||
presentation: ClipboardPresentationId,
|
||||
value: SecretBytes,
|
||||
},
|
||||
GenerateField(EntryFieldId),
|
||||
SaveDocument {
|
||||
config: Box<Config>,
|
||||
@@ -326,6 +345,8 @@ pub struct App {
|
||||
otp_pending: bool,
|
||||
hotp_confirmation: Option<OtpUiRequest>,
|
||||
clipboard_request: Option<SecretBytes>,
|
||||
clipboard_presentation: Option<ClipboardPresentation>,
|
||||
next_clipboard_presentation: u64,
|
||||
remaining_lease: Option<std::time::Duration>,
|
||||
terminal_size: (u16, u16),
|
||||
ticks: u64,
|
||||
@@ -372,6 +393,8 @@ impl App {
|
||||
otp_pending: false,
|
||||
hotp_confirmation: None,
|
||||
clipboard_request: None,
|
||||
clipboard_presentation: None,
|
||||
next_clipboard_presentation: 0,
|
||||
remaining_lease: None,
|
||||
terminal_size: (0, 0),
|
||||
ticks: 0,
|
||||
@@ -489,8 +512,23 @@ impl App {
|
||||
pub fn hotp_confirmation(&self) -> bool {
|
||||
self.hotp_confirmation.is_some()
|
||||
}
|
||||
pub fn take_clipboard_request(&mut self) -> Option<SecretBytes> {
|
||||
self.clipboard_request.take()
|
||||
pub fn take_clipboard_effect(&mut self) -> Option<AppEffect> {
|
||||
let value = self.clipboard_request.take()?;
|
||||
Some(self.begin_clipboard_presentation(value))
|
||||
}
|
||||
|
||||
pub fn clipboard_remaining_seconds(&self) -> Option<u64> {
|
||||
self.clipboard_remaining_seconds_at(Instant::now())
|
||||
}
|
||||
|
||||
fn clipboard_remaining_seconds_at(&self, now: Instant) -> Option<u64> {
|
||||
let deadline = self.clipboard_presentation?.deadline?;
|
||||
let milliseconds = deadline.saturating_duration_since(now).as_millis();
|
||||
Some(u64::try_from(milliseconds.div_ceil(1_000)).unwrap_or(u64::MAX))
|
||||
}
|
||||
|
||||
pub fn clipboard_pending(&self) -> bool {
|
||||
self.clipboard_presentation.is_some()
|
||||
}
|
||||
|
||||
pub fn begin_git_operation(&mut self, label: &str) {
|
||||
@@ -595,7 +633,10 @@ impl App {
|
||||
if result.token.generation != self.generation || !self.pending.contains(&result.token.id) {
|
||||
return ResultDisposition::Stale;
|
||||
}
|
||||
if !matches!(result.payload, Ok(AsyncPayload::GitProgress(_))) {
|
||||
if !matches!(
|
||||
result.payload,
|
||||
Ok(AsyncPayload::GitProgress(_) | AsyncPayload::ClipboardStarted { .. })
|
||||
) {
|
||||
self.pending.remove(&result.token.id);
|
||||
}
|
||||
match result.payload {
|
||||
@@ -645,18 +686,39 @@ impl App {
|
||||
}
|
||||
self.focus = PaneFocus::Main;
|
||||
}
|
||||
Ok(AsyncPayload::ClipboardFinished(disposition)) => {
|
||||
if self.mode == Mode::Locked {
|
||||
Ok(AsyncPayload::ClipboardStarted {
|
||||
presentation,
|
||||
deadline,
|
||||
}) => {
|
||||
if let Some(active) = self
|
||||
.clipboard_presentation
|
||||
.as_mut()
|
||||
.filter(|active| active.id == presentation)
|
||||
{
|
||||
active.deadline = Some(deadline);
|
||||
self.status = "Secret copied".to_owned();
|
||||
}
|
||||
}
|
||||
Ok(AsyncPayload::ClipboardFinished {
|
||||
presentation,
|
||||
result,
|
||||
}) => {
|
||||
if self
|
||||
.clipboard_presentation
|
||||
.is_none_or(|active| active.id != presentation)
|
||||
{
|
||||
return ResultDisposition::Applied;
|
||||
}
|
||||
self.status = match disposition {
|
||||
ClipboardDisposition::RestoredPrevious => {
|
||||
self.clipboard_presentation = None;
|
||||
self.status = match result {
|
||||
Ok(ClipboardDisposition::RestoredPrevious) => {
|
||||
"Clipboard restored to its previous value".to_owned()
|
||||
}
|
||||
ClipboardDisposition::Cleared => "Clipboard secret cleared".to_owned(),
|
||||
ClipboardDisposition::PreservedNewer => {
|
||||
Ok(ClipboardDisposition::Cleared) => "Clipboard secret cleared".to_owned(),
|
||||
Ok(ClipboardDisposition::PreservedNewer) => {
|
||||
"Clipboard changed; the newer value was preserved".to_owned()
|
||||
}
|
||||
Err(error) => format!("Clipboard presentation failed: {error}"),
|
||||
};
|
||||
}
|
||||
Ok(AsyncPayload::GeneratedField { target, password }) => {
|
||||
@@ -1046,44 +1108,11 @@ impl App {
|
||||
PaneFocus::Main => PaneFocus::Sidebar,
|
||||
};
|
||||
}
|
||||
Action::Reveal => {
|
||||
let revealed = if self.mode == Mode::Editor {
|
||||
self.editor
|
||||
.as_mut()
|
||||
.is_some_and(EntryEditor::reveal_focused)
|
||||
} else {
|
||||
self.viewer
|
||||
.as_mut()
|
||||
.is_some_and(EntryViewer::reveal_focused)
|
||||
};
|
||||
if revealed {
|
||||
self.status = "Focused sensitive field revealed".to_owned();
|
||||
}
|
||||
}
|
||||
Action::Hide => {
|
||||
let hidden = if self.mode == Mode::Editor {
|
||||
self.editor.as_mut().is_some_and(EntryEditor::hide_revealed)
|
||||
} else {
|
||||
self.viewer.as_mut().is_some_and(EntryViewer::hide_revealed)
|
||||
};
|
||||
if hidden {
|
||||
self.status = "Sensitive field hidden".to_owned();
|
||||
}
|
||||
}
|
||||
Action::Copy => {
|
||||
if let Some(viewer) = self.viewer.as_ref() {
|
||||
match viewer.copy_focused() {
|
||||
Ok(value) => {
|
||||
self.status = self.config.as_ref().map_or_else(
|
||||
|| "Copying focused field…".to_owned(),
|
||||
|config| {
|
||||
format!(
|
||||
"Copied focused field; cleanup in {}s",
|
||||
config.clipboard_timeout().duration().as_secs()
|
||||
)
|
||||
},
|
||||
);
|
||||
return AppEffect::CopyFocused(value);
|
||||
return self.begin_clipboard_presentation(value);
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
@@ -1660,6 +1689,20 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_clipboard_presentation(&mut self, value: SecretBytes) -> AppEffect {
|
||||
let presentation = ClipboardPresentationId(self.next_clipboard_presentation);
|
||||
self.next_clipboard_presentation = self.next_clipboard_presentation.wrapping_add(1);
|
||||
self.clipboard_presentation = Some(ClipboardPresentation {
|
||||
id: presentation,
|
||||
deadline: None,
|
||||
});
|
||||
self.status = "Copying secret to the native clipboard…".to_owned();
|
||||
AppEffect::CopyFocused {
|
||||
presentation,
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_workflow(&mut self, workflow: WorkflowAction, request: Option<CommandRequest>) {
|
||||
let form = match (workflow, request) {
|
||||
(WorkflowAction::Initialize, Some(CommandRequest::Init(request))) => {
|
||||
@@ -1935,6 +1978,7 @@ impl App {
|
||||
self.qr_popup = None;
|
||||
self.uri_popup = None;
|
||||
self.clipboard_request = None;
|
||||
self.clipboard_presentation = None;
|
||||
self.authentication_pending = None;
|
||||
self.selected_entry = None;
|
||||
self.viewer = None;
|
||||
@@ -1968,6 +2012,7 @@ impl App {
|
||||
self.qr_popup = None;
|
||||
self.uri_popup = None;
|
||||
self.clipboard_request = None;
|
||||
self.clipboard_presentation = None;
|
||||
self.remaining_lease = None;
|
||||
self.status = if discarded_edit {
|
||||
format!("Locked: {reason}; unsaved edits were discarded")
|
||||
@@ -2012,11 +2057,7 @@ impl App {
|
||||
return false;
|
||||
};
|
||||
if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
|
||||
if let Some(viewer) = self.viewer.as_mut() {
|
||||
viewer.hide_revealed();
|
||||
}
|
||||
if let Some(editor) = self.editor.as_mut() {
|
||||
editor.hide_revealed();
|
||||
editor.end_input();
|
||||
}
|
||||
self.suspended_mode = Some(current);
|
||||
@@ -2050,6 +2091,7 @@ impl App {
|
||||
self.qr_popup = None;
|
||||
self.uri_popup = None;
|
||||
self.clipboard_request = None;
|
||||
self.clipboard_presentation = None;
|
||||
self.status = "Locked".to_owned();
|
||||
} else if current == Mode::Locked {
|
||||
self.status = "Authentication required".to_owned();
|
||||
@@ -2220,6 +2262,104 @@ mod tests {
|
||||
assert_eq!(app.focus(), PaneFocus::Main);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_countdown_resets_finishes_and_cannot_survive_lock() {
|
||||
let mut app = App::new();
|
||||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||||
let first = match app.dispatch(Action::Copy) {
|
||||
AppEffect::CopyFocused { presentation, .. } => presentation,
|
||||
effect => panic!("unexpected effect: {effect:?}"),
|
||||
};
|
||||
let first_token = app.begin_request();
|
||||
let first_deadline = Instant::now() + std::time::Duration::from_secs(3);
|
||||
assert_eq!(
|
||||
app.apply_result(AsyncResult {
|
||||
token: first_token,
|
||||
payload: Ok(AsyncPayload::ClipboardStarted {
|
||||
presentation: first,
|
||||
deadline: first_deadline,
|
||||
}),
|
||||
}),
|
||||
ResultDisposition::Applied
|
||||
);
|
||||
assert_eq!(
|
||||
app.clipboard_remaining_seconds_at(first_deadline - std::time::Duration::from_secs(3)),
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(
|
||||
app.clipboard_remaining_seconds_at(first_deadline - std::time::Duration::from_secs(1)),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(app.clipboard_remaining_seconds_at(first_deadline), Some(0));
|
||||
|
||||
let second = match app.dispatch(Action::Copy) {
|
||||
AppEffect::CopyFocused { presentation, .. } => presentation,
|
||||
effect => panic!("unexpected effect: {effect:?}"),
|
||||
};
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(
|
||||
app.apply_result(AsyncResult {
|
||||
token: first_token,
|
||||
payload: Ok(AsyncPayload::ClipboardFinished {
|
||||
presentation: first,
|
||||
result: Err(ClipboardError::Cancelled),
|
||||
}),
|
||||
}),
|
||||
ResultDisposition::Applied
|
||||
);
|
||||
assert!(app.clipboard_pending());
|
||||
assert_eq!(app.clipboard_remaining_seconds(), None);
|
||||
|
||||
let second_token = app.begin_request();
|
||||
let second_deadline = Instant::now() + std::time::Duration::from_secs(5);
|
||||
app.apply_result(AsyncResult {
|
||||
token: second_token,
|
||||
payload: Ok(AsyncPayload::ClipboardStarted {
|
||||
presentation: second,
|
||||
deadline: second_deadline,
|
||||
}),
|
||||
});
|
||||
assert_eq!(
|
||||
app.clipboard_remaining_seconds_at(second_deadline - std::time::Duration::from_secs(5)),
|
||||
Some(5)
|
||||
);
|
||||
app.apply_result(AsyncResult {
|
||||
token: second_token,
|
||||
payload: Ok(AsyncPayload::ClipboardFinished {
|
||||
presentation: second,
|
||||
result: Ok(ClipboardDisposition::Cleared),
|
||||
}),
|
||||
});
|
||||
assert!(!app.clipboard_pending());
|
||||
assert_eq!(app.clipboard_remaining_seconds(), None);
|
||||
assert_eq!(app.status(), "Clipboard secret cleared");
|
||||
|
||||
let third = match app.dispatch(Action::Copy) {
|
||||
AppEffect::CopyFocused { presentation, .. } => presentation,
|
||||
effect => panic!("unexpected effect: {effect:?}"),
|
||||
};
|
||||
let third_token = app.begin_request();
|
||||
app.apply_result(AsyncResult {
|
||||
token: third_token,
|
||||
payload: Ok(AsyncPayload::ClipboardStarted {
|
||||
presentation: third,
|
||||
deadline: Instant::now() + std::time::Duration::from_secs(5),
|
||||
}),
|
||||
});
|
||||
assert!(matches!(app.dispatch(Action::Lock), AppEffect::ManualLock));
|
||||
assert!(!app.clipboard_pending());
|
||||
assert_eq!(
|
||||
app.apply_result(AsyncResult {
|
||||
token: third_token,
|
||||
payload: Ok(AsyncPayload::ClipboardFinished {
|
||||
presentation: third,
|
||||
result: Ok(ClipboardDisposition::Cleared),
|
||||
}),
|
||||
}),
|
||||
ResultDisposition::Stale
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_must_match_the_pending_entry_before_viewer_transition() {
|
||||
let mut app = App::new();
|
||||
|
||||
Reference in New Issue
Block a user