diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 1ecbf90..d35e19b 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -26,8 +26,8 @@ use iced::{ Background, Border, Color, Element, Event, Length, Point, Rectangle, Renderer, Size, Subscription, Task, Theme, event, keyboard, mouse, time, touch, widget::{ - button, canvas, column, container, mouse_area, pane_grid, row, scrollable, text, - text_editor, text_input, tooltip, + button, canvas, column, container, mouse_area, pane_grid, progress_bar, row, scrollable, + text, text_editor, text_input, tooltip, }, window, }; @@ -5341,11 +5341,70 @@ fn otp_code_text(display: &OtpDisplay) -> String { ) } +fn matching_otp_display<'a>(app: &'a App, entry: &str) -> Option<&'a OtpDisplay> { + app.sensitive + .otp + .as_ref() + .filter(|display| display.entry == entry) +} + +fn otp_progress(display: &OtpDisplay) -> Option<(u64, u64)> { + display.validity.period().map(|period| { + ( + display + .remaining_at(display.observed_at) + .unwrap_or_default() + .min(period), + period.max(1), + ) + }) +} + fn viewer_view<'a>(app: &'a App, editor: &'a EntryEditor) -> Element<'a, Message> { let entry = editor.entry(); - let mut rows = - column![text("Up/Down/Home/End select fields · ⌘C copies the selected value").size(12),] - .spacing(8); + let mut rows = column![].spacing(8); + if let Some(display) = matching_otp_display(app, &entry) { + let mut code = column![ + text(match display.metadata.kind() { + OtpKind::Totp => "Current one-time password", + OtpKind::Hotp => "Generated one-time password", + }) + .size(13), + text(otp_code_text(display)).size(42), + ] + .align_x(iced::Alignment::Center) + .spacing(2); + if let Some((remaining, period)) = otp_progress(display) { + code = code.push(text(format!("{remaining}s remaining"))).push( + progress_bar(0.0..=period as f32, remaining as f32).girth(Length::Fixed(6.0)), + ); + } else if let Some(counter) = display.validity.counter() { + code = code.push(text(format!("HOTP counter {counter}"))); + } + rows = rows.push( + container(code) + .padding([10, 12]) + .width(Length::Fill) + .center_x(Length::Fill) + .style(entry_value_style), + ); + } else if app.otp_pending + && editor + .document() + .fields() + .iter() + .any(|field| field.metadata().otp().is_some()) + { + rows = rows.push( + container(text("Generating one-time password…").size(15)) + .padding(10) + .width(Length::Fill) + .center_x(Length::Fill) + .style(entry_value_style), + ); + } + rows = + rows.push(text("Up/Down/Home/End select fields · ⌘C copies the selected value").size(12)); for field in editor.document().display_fields() { let id = field.id(); @@ -5392,34 +5451,6 @@ fn viewer_view<'a>(app: &'a App, editor: &'a EntryEditor) -> Element<'a, Message || format!("counter {}", otp.counter().unwrap_or_default()), |period| format!("{period}s period"), ); - let code = app - .sensitive - .otp - .as_ref() - .filter(|display| display.entry == entry); - field_view = field_view.push(match code { - Some(display) => column![ - text(otp_code_text(display)).size(28), - text(display.remaining_at(display.observed_at).map_or_else( - || format!( - "HOTP counter {}", - display.validity.counter().unwrap_or_default() - ), - |remaining| format!("Valid for {remaining}s"), - )) - .size(12), - ] - .spacing(1), - None if app.otp_pending => column![text("Generating one-time password…").size(13)], - None => column![ - text(if otp.kind() == OtpKind::Hotp { - "Generate the next HOTP code to advance its counter." - } else { - "One-time password unavailable. Refresh to retry." - }) - .size(13) - ], - }); field_view = field_view.push( text(format!( "{:?} · {} · {} · {:?} · {} digits · {cadence}", @@ -6783,6 +6814,8 @@ mod tests { }; assert_eq!(display.remaining_at(59), Some(1)); assert_eq!(display.remaining_at(60), Some(0)); + assert_eq!(otp_progress(&display), Some((1, 30))); + assert_eq!(otp_code_text(&display), "9428 7082"); let mut hotp_document = storage .create_document("otp/rfc4226", &mut provider) diff --git a/docs/desktop-audit.md b/docs/desktop-audit.md index a73550b..bb85d08 100644 --- a/docs/desktop-audit.md +++ b/docs/desktop-audit.md @@ -25,7 +25,7 @@ requires every registered action ID to remain present in this document. | Base pass: `grep` | `search-contents` (Edit) | Authenticated decrypted-search form and typed result activation | Yes | | Base pass: `mv`/`rename`, `cp`/`copy`, `rm`/`remove` | `move-entry`, `copy-entry`, `delete-entry` (Entry) | Sidebar context controls and validated mutation forms; delete is confirmed | Yes | | KeePass migration: additive KDBX import and quick add | `import-kdbx` (Tools) | Native file/key picker, protected password input, explicit confirmation, and storage-owned tree refresh | Yes | -| Pass OTP: code/show and timed copy | `generate-otp`, `copy-otp` (Entry) | OTP panel shows typed metadata, code, validity, and HOTP confirmation | Yes | +| Pass OTP: code/show and timed copy | `generate-otp`, `copy-otp` (Entry) | Unlocked entry header shows the large code and storage-period countdown bar; the OTP field retains metadata/actions and HOTP confirmation | Yes | | Pass OTP: insert/add/append and validate | `import-otp` (Entry) | URI/QR import form; storage validates, replaces, and commits | Yes | | Pass OTP: URI terminal/clipboard/QR | `show-otp-uri`, `copy-otp-uri`, `show-otp-qr` (Entry) | Explicit secret view, timed copy, and storage-provided QR matrix | Yes | | Pass OTP: remove | `remove-otp` (Entry) | Explicit permanent-removal confirmation | Yes |