Fix unlocked TUI visibility and clipboard feedback

This commit is contained in:
2026-08-10 18:32:57 +02:00
parent 2eef49c5c3
commit 75d4ead3d8
8 changed files with 673 additions and 288 deletions

View File

@@ -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();