Implement the authenticated structured entry viewer

This commit is contained in:
Hermes Agent
2026-08-10 06:45:39 +00:00
parent 57a79b7d21
commit cf7a6216ac
8 changed files with 758 additions and 28 deletions

View File

@@ -9,8 +9,13 @@ pub mod runtime;
pub mod sidebar;
pub mod terminal;
pub mod ui;
pub mod viewer;
use std::{io, time::Duration};
use std::{
io,
sync::mpsc::{self, Sender},
time::Duration,
};
use crossterm::event::{self, Event, KeyEventKind};
use ratatui::DefaultTerminal;
@@ -23,11 +28,29 @@ use crate::{
const TICK_INTERVAL: Duration = Duration::from_millis(250);
#[derive(Default)]
struct ClipboardCancellations(Vec<Sender<()>>);
impl ClipboardCancellations {
fn register(&mut self, cancellation: Sender<()>) {
self.0.push(cancellation);
}
}
impl Drop for ClipboardCancellations {
fn drop(&mut self) {
for cancellation in self.0.drain(..) {
let _ignored = cancellation.send(());
}
}
}
/// Run the interactive event loop. Polling keeps input responsive while storage
/// work runs on the executor and periodic repaints are pending.
pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
let mut app = App::new();
let executor = AsyncExecutor::new();
let mut clipboard_cancellations = ClipboardCancellations::default();
let mut authentication = None;
let mut authentication_initialized = false;
let startup = app.begin_latest_request();
@@ -51,7 +74,7 @@ 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, event);
apply_authentication_event(&mut app, coordinator, &executor, event);
}
let size = terminal.size()?;
@@ -61,7 +84,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
app.tick();
if let Some(coordinator) = authentication.as_mut() {
if let Some(event) = coordinator.poll_lease() {
apply_authentication_event(&mut app, event);
apply_authentication_event(&mut app, coordinator, &executor, event);
}
app.update_remaining_lease(coordinator.remaining_time());
}
@@ -73,7 +96,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
if let Some(coordinator) = authentication.as_mut()
&& let Some(event) = coordinator.touch_user_activity()
{
apply_authentication_event(&mut app, event);
apply_authentication_event(&mut app, coordinator, &executor, event);
}
if app.sidebar().is_editing_filter() {
if handle_filter_key(&mut app, key.code) {
@@ -98,6 +121,33 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
);
}
}
AppEffect::CopyFocused(value) => {
if let Some(config) = app.config().cloned() {
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::ManualLock => {
if let Some(coordinator) = authentication.as_mut()
&& let Err(error) = coordinator.lock()
@@ -144,16 +194,52 @@ fn load_startup() -> Result<StartupData, String> {
Ok(StartupData { config, tree, key })
}
fn apply_authentication_event(app: &mut App, event: AuthenticationEvent) {
fn apply_authentication_event(
app: &mut App,
coordinator: &AuthenticationCoordinator,
executor: &AsyncExecutor,
event: AuthenticationEvent,
) {
match event {
AuthenticationEvent::Granted(entry) => {
app.authentication_granted(entry);
if !app.authentication_granted(entry.clone()) {
return;
}
let (Some(config), Some(handle)) = (app.config().cloned(), coordinator.handle()) else {
app.authentication_failed(
"authentication completed without an active secure-store lease".to_owned(),
);
return;
};
let token = app.begin_latest_request();
executor.submit(token, move || {
load_document(&config, &entry, handle).map(|document| {
AsyncPayload::DocumentLoaded {
entry,
document: Box::new(document),
}
})
});
}
AuthenticationEvent::Failed(error) => app.authentication_failed(error),
AuthenticationEvent::Expired => app.forced_relock("authentication lease expired"),
}
}
fn load_document(
config: &ironstorage::config::Config,
entry: &str,
mut handle: ironstorage::authentication::NativeAuthenticationHandle,
) -> Result<ironstorage::document::EntryDocument, String> {
let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?;
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
.map_err(|error| error.to_string())?;
ironstorage::document::EntryDocumentService::new(&repository, &keys)
.open(entry, &mut handle)
.map_err(|error| error.to_string())
}
fn load_tree(config: &ironstorage::config::Config) -> Result<ironstorage::read::TreeModel, String> {
let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?;