Show OTP codes above desktop entry fields

This commit is contained in:
2026-08-11 09:15:04 +02:00
parent bf75465642
commit d6bcbbec99
2 changed files with 67 additions and 34 deletions

View File

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

View File

@@ -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 |