Fix unlocked TUI visibility and clipboard feedback
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user