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

@@ -18,8 +18,8 @@ pub mod workflow;
use std::{
io,
path::PathBuf,
sync::mpsc::{self, Sender},
time::Duration,
sync::mpsc::{self, Receiver, Sender},
time::{Duration, Instant},
};
use crossterm::event::{self, Event, KeyEventKind};
@@ -40,26 +40,41 @@ use crate::{
const TICK_INTERVAL: Duration = Duration::from_millis(250);
struct ClipboardCancellation {
sender: Sender<()>,
finished: Receiver<Result<(), ironstorage::presentation::ClipboardError>>,
}
#[derive(Default)]
struct ClipboardCancellations(Vec<Sender<()>>);
struct ClipboardCancellations(Option<ClipboardCancellation>);
impl ClipboardCancellations {
fn register(&mut self, cancellation: Sender<()>) {
self.0.push(cancellation);
fn replace(
&mut self,
cancellation: Sender<()>,
finished: Receiver<Result<(), ironstorage::presentation::ClipboardError>>,
) -> Option<Receiver<Result<(), ironstorage::presentation::ClipboardError>>> {
let previous = self.0.take().map(|active| {
let _ignored = active.sender.send(());
active.finished
});
self.0 = Some(ClipboardCancellation {
sender: cancellation,
finished,
});
previous
}
fn cancel_all(&mut self) {
for cancellation in self.0.drain(..) {
let _ignored = cancellation.send(());
if let Some(cancellation) = self.0.take() {
let _ignored = cancellation.sender.send(());
}
}
}
impl Drop for ClipboardCancellations {
fn drop(&mut self) {
for cancellation in self.0.drain(..) {
let _ignored = cancellation.send(());
}
self.cancel_all();
}
}
@@ -83,10 +98,10 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
for result in executor.drain() {
app.apply_result(result);
}
if let Some(value) = app.take_clipboard_request() {
if let Some(effect) = app.take_clipboard_effect() {
apply_app_effect(
&mut app,
AppEffect::CopyFocused(value),
effect,
&mut authentication,
&executor,
&mut git_control,
@@ -108,7 +123,14 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
if let Some(coordinator) = authentication.as_mut()
&& let Some(event) = coordinator.completion()
{
apply_authentication_event(&mut app, coordinator, &executor, &mut git_control, event);
apply_authentication_event(
&mut app,
coordinator,
&executor,
&mut git_control,
&mut clipboard_cancellations,
event,
);
}
let unix_seconds = current_unix_seconds().map_err(io::Error::other)?;
@@ -127,6 +149,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
coordinator,
&executor,
&mut git_control,
&mut clipboard_cancellations,
event,
);
}
@@ -145,6 +168,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
coordinator,
&executor,
&mut git_control,
&mut clipboard_cancellations,
event,
);
}
@@ -218,6 +242,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
coordinator,
&executor,
&mut git_control,
&mut clipboard_cancellations,
event,
);
}
@@ -378,35 +403,76 @@ fn apply_app_effect(
});
}
}
AppEffect::CopyFocused(value) => {
if let Some(config) = app.config().cloned() {
app.report_status(format!(
"Secret copied; cleanup in {}s",
config.clipboard_timeout().duration().as_secs()
));
let (cancel, cancellation) = mpsc::channel();
clipboard_cancellations.register(cancel);
let token = app.begin_request();
executor.submit(token, move || {
let mut clipboard = ironstorage::presentation::NativeClipboardManager::system(
config.clipboard_timeout(),
)
.map_err(|error| error.to_string())?;
clipboard
.copy_with(&value, |duration| {
match cancellation.recv_timeout(duration) {
Err(mpsc::RecvTimeoutError::Timeout) => {
ironstorage::presentation::ClipboardWait::Elapsed
}
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
ironstorage::presentation::ClipboardWait::Cancelled
}
}
})
.map(AsyncPayload::ClipboardFinished)
.map_err(|error| error.to_string())
AppEffect::CopyFocused {
presentation,
value,
} => {
let token = app.begin_request();
let Some(config) = app.config().cloned() else {
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(AsyncPayload::ClipboardFinished {
presentation,
result: Err(ironstorage::presentation::ClipboardError::Unavailable),
}),
});
}
return;
};
let (cancel, cancellation) = mpsc::channel();
let (finished, completion) = mpsc::channel();
let previous = clipboard_cancellations.replace(cancel, completion);
let started = executor.clipboard_started_reporter(token);
executor.submit(token, move || {
// Finish the old cleanup before taking the new clipboard snapshot,
// otherwise the new presentation could later restore an older secret.
let previous = previous.map_or(Ok(()), |previous| {
previous.recv().unwrap_or(Err(
ironstorage::presentation::ClipboardError::CleanupFailed,
))
});
let payload = match (previous, cancellation.try_recv()) {
(Err(error), _) => AsyncPayload::ClipboardFinished {
presentation,
result: Err(error),
},
(Ok(()), Err(mpsc::TryRecvError::Empty)) => run_clipboard_presentation(
ironstorage::presentation::NativeClipboardManager::system(
config.clipboard_timeout(),
),
presentation,
value,
started,
move |deadline| match cancellation
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
{
Err(mpsc::RecvTimeoutError::Timeout) => {
ironstorage::presentation::ClipboardWait::Elapsed
}
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
ironstorage::presentation::ClipboardWait::Cancelled
}
},
),
(Ok(()), Ok(())) | (Ok(()), Err(mpsc::TryRecvError::Disconnected)) => {
AsyncPayload::ClipboardFinished {
presentation,
result: Err(ironstorage::presentation::ClipboardError::Cancelled),
}
}
};
let cleanup = match &payload {
AsyncPayload::ClipboardFinished {
result: Ok(_) | Err(ironstorage::presentation::ClipboardError::Cancelled),
..
} => Ok(()),
AsyncPayload::ClipboardFinished {
result: Err(error), ..
} => Err(*error),
_ => unreachable!("clipboard worker returns a clipboard result"),
};
let _ignored = finished.send(cleanup);
Ok(payload)
});
}
AppEffect::GenerateField(target) => {
let token = app.begin_request();
@@ -493,6 +559,34 @@ fn apply_app_effect(
}
}
fn run_clipboard_presentation<B, S, W>(
clipboard: Result<
ironstorage::presentation::ClipboardManager<B>,
ironstorage::presentation::ClipboardError,
>,
presentation: crate::app::ClipboardPresentationId,
value: ironstorage::repository::SecretBytes,
started: S,
wait: W,
) -> AsyncPayload
where
B: ironstorage::presentation::ClipboardBackend,
S: FnOnce(crate::app::ClipboardPresentationId, Instant),
W: FnOnce(Instant) -> ironstorage::presentation::ClipboardWait,
{
let result = clipboard.and_then(|mut clipboard| {
clipboard.copy_with(&value, |duration| {
let deadline = Instant::now() + duration;
started(presentation, deadline);
wait(deadline)
})
});
AsyncPayload::ClipboardFinished {
presentation,
result,
}
}
fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool {
matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat)
}
@@ -948,6 +1042,7 @@ fn apply_authentication_event(
coordinator: &AuthenticationCoordinator,
executor: &AsyncExecutor,
git_control: &mut Option<ironstorage::git::GitOperationControl>,
clipboard_cancellations: &mut ClipboardCancellations,
event: AuthenticationEvent,
) {
match event {
@@ -1033,10 +1128,20 @@ fn apply_authentication_event(
app.authentication_failed(message);
}
}
AuthenticationEvent::Expired => app.forced_relock("authentication lease expired"),
AuthenticationEvent::Expired => {
handle_authentication_expiry(app, clipboard_cancellations);
}
}
}
fn handle_authentication_expiry(
app: &mut App,
clipboard_cancellations: &mut ClipboardCancellations,
) {
clipboard_cancellations.cancel_all();
app.forced_relock("authentication lease expired");
}
fn load_document(
config: &ironstorage::config::Config,
entry: &str,
@@ -1403,7 +1508,11 @@ mod tests {
};
use super::*;
use crate::{action::Action, editor::EntryEditor, viewer::test_support::fixture_document_from};
use crate::{
action::Action,
editor::EntryEditor,
viewer::test_support::{fixture_document, fixture_document_from},
};
#[test]
fn press_and_terminal_repeat_events_dispatch_but_release_does_not() {
@@ -1455,11 +1564,30 @@ mod tests {
#[test]
fn terminal_ownership_loss_cancels_presentations_and_forces_relock() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
let presentation = match app.dispatch(Action::Copy) {
AppEffect::CopyFocused { presentation, .. } => presentation,
effect => panic!("unexpected effect: {effect:?}"),
};
let mut authentication = None;
let mut git_control = None;
let mut clipboard = ClipboardCancellations::default();
let (first_cancel, first_cancelled) = std::sync::mpsc::channel();
let (first_finished, first_completion) = std::sync::mpsc::channel();
assert!(clipboard.replace(first_cancel, first_completion).is_none());
let (cancel, cancelled) = std::sync::mpsc::channel();
clipboard.register(cancel);
let (_finished, completion) = std::sync::mpsc::channel();
let previous = clipboard
.replace(cancel, completion)
.expect("previous clipboard completion");
first_cancelled
.recv_timeout(std::time::Duration::from_secs(1))
.expect("replacement cancels the previous clipboard presentation");
first_finished.send(Ok(())).expect("finish first cleanup");
previous
.recv_timeout(std::time::Duration::from_secs(1))
.expect("replacement waits for previous cleanup")
.expect("previous cleanup succeeds");
handle_terminal_ownership_lost(
&mut app,
@@ -1469,10 +1597,100 @@ mod tests {
);
assert_eq!(app.mode(), crate::app::Mode::Locked);
assert!(!app.clipboard_pending());
assert!(app.status().contains("terminal ownership was lost"));
cancelled
.recv_timeout(std::time::Duration::from_secs(1))
.expect("clipboard cancellation");
assert_eq!(presentation.0, 0);
}
#[test]
fn authentication_expiry_cancels_the_active_clipboard_presentation() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
assert!(matches!(
app.dispatch(Action::Copy),
AppEffect::CopyFocused { .. }
));
let mut clipboard = ClipboardCancellations::default();
let (cancel, cancelled) = std::sync::mpsc::channel();
let (_finished, completion) = std::sync::mpsc::channel();
assert!(clipboard.replace(cancel, completion).is_none());
handle_authentication_expiry(&mut app, &mut clipboard);
cancelled
.recv_timeout(Duration::from_secs(1))
.expect("clipboard cancellation on expiry");
assert_eq!(app.mode(), crate::app::Mode::Locked);
assert!(!app.clipboard_pending());
}
#[derive(Default)]
struct MemoryClipboard(Vec<u8>);
impl ironstorage::presentation::ClipboardBackend for MemoryClipboard {
fn read(
&mut self,
) -> Result<
ironstorage::presentation::ClipboardContent,
ironstorage::presentation::ClipboardError,
> {
if self.0.is_empty() {
Ok(ironstorage::presentation::ClipboardContent::EmptyOrNonText)
} else {
Ok(ironstorage::presentation::ClipboardContent::text(
self.0.clone(),
))
}
}
fn write(
&mut self,
value: &SecretBytes,
) -> Result<(), ironstorage::presentation::ClipboardError> {
self.0 = value.expose().to_vec();
Ok(())
}
fn clear(&mut self) -> Result<(), ironstorage::presentation::ClipboardError> {
self.0.clear();
Ok(())
}
}
#[test]
fn clipboard_worker_reports_the_deadline_used_by_its_cleanup_wait() {
let presentation = crate::app::ClipboardPresentationId(7);
let timeout = ironstorage::presentation::ClipboardTimeout::new(Duration::from_secs(3))
.expect("timeout");
let mut started = None;
let mut waited_until = None;
let payload = run_clipboard_presentation(
Ok(ironstorage::presentation::ClipboardManager::new(
MemoryClipboard(b"previous".to_vec()),
timeout,
)),
presentation,
SecretBytes::new(b"copied".to_vec()),
|reported, deadline| started = Some((reported, deadline)),
|deadline| {
waited_until = Some(deadline);
ironstorage::presentation::ClipboardWait::Elapsed
},
);
let (reported, deadline) = started.expect("clipboard start");
assert_eq!(reported, presentation);
assert_eq!(waited_until, Some(deadline));
assert!(matches!(
payload,
AsyncPayload::ClipboardFinished {
presentation: finished,
result: Ok(ironstorage::presentation::ClipboardDisposition::RestoredPrevious),
} if finished == presentation
));
}
#[test]