Improve TUI OTP readability

This commit is contained in:
Hermes Agent
2026-08-10 12:22:53 +00:00
parent d3427f3be6
commit cec1cc3500
6 changed files with 433 additions and 65 deletions

View File

@@ -34,7 +34,9 @@ submits a command or creates extra entry lines. Losing terminal ownership
(including terminal suspension/focus loss) immediately revokes the lease, (including terminal suspension/focus loss) immediately revokes the lease,
cancels Git and clipboard work, removes plaintext presentation state, and cancels Git and clipboard work, removes plaintext presentation state, and
returns to the locked screen. `NO_COLOR` or `TERM=dumb` selects the monochrome returns to the locked screen. `NO_COLOR` or `TERM=dumb` selects the monochrome
fallback while preserving focus through text and bold attributes. fallback while preserving focused selections through reverse-video attributes.
Selected rows override nested field colors so labels, masked values, metadata,
and OTP codes keep one high-contrast foreground across the complete selection.
## Pass and pass-otp coverage matrix ## Pass and pass-otp coverage matrix
@@ -65,7 +67,7 @@ where one is listed.
| git fetch/sync | cancellable progress view | — | `:git fetch …`, `:git sync …` | | git fetch/sync | cancellable progress view | — | `:git fetch …`, `:git sync …` |
| git pull/push | cancellable progress view | `g p` / `g P` | `:git pull …`, `:git push …` | | git pull/push | cancellable progress view | `g p` / `g P` | `:git pull …`, `:git push …` |
| git conflict resolution | conflict view | — | `:git resolve-local`, `:git resolve-remote` | | git conflict resolution | conflict view | — | `:git resolve-local`, `:git resolve-remote` |
| otp code | focused OTP field | `o c` | `:otp code ENTRY` | | otp code | responsive entry OTP panel/compact field | `o c` | `:otp code ENTRY` |
| otp code clipboard | clipboard feedback | `o y` | `:otp code --clip ENTRY` | | otp code clipboard | clipboard feedback | `o y` | `:otp code --clip ENTRY` |
| otp insert/append | masked OTP forms | `o i` / `o a` | `:otp insert …`, `:otp append …` | | otp insert/append | masked OTP forms | `o i` / `o a` | `:otp insert …`, `:otp append …` |
| otp URI terminal/clipboard/QR | secret popup/clipboard/QR | `o u` / `o x` / `o q` | `:otp uri [--clip\|--qrcode] ENTRY` | | otp URI terminal/clipboard/QR | secret popup/clipboard/QR | `o u` / `o x` / `o q` | `:otp uri [--clip\|--qrcode] ENTRY` |
@@ -79,6 +81,13 @@ ticks, storage completion, Git progress, and clipboard timers do not extend the
authentication lease. Clipboard and QR payloads are storage-produced, authentication lease. Clipboard and QR payloads are storage-produced,
zeroizing values and disappear immediately on relock. zeroizing values and disappear immediately on relock.
Opening an entry with a TOTP field requests its read-only code without requiring
field focus. If the viewer pane can retain both the code and field list, the TUI
renders five-row digit-only glyphs and a storage-period-driven countdown bar.
Shorter panes fall back to a leading inline code and remaining-seconds label.
The displayed TOTP survives field navigation, refreshes at its exact storage
boundary, and is removed when the entry closes or the application relocks.
`:quit` is a TUI convenience. Shell-completion script generation is a CLI `:quit` is a TUI convenience. Shell-completion script generation is a CLI
build-time/integration surface; interactive Tab completion replaces it here. build-time/integration surface; interactive Tab completion replaces it here.

View File

@@ -138,6 +138,9 @@ impl OtpDisplay {
pub fn counter(&self) -> Option<u64> { pub fn counter(&self) -> Option<u64> {
self.validity.counter() self.validity.counter()
} }
pub fn period_seconds(&self) -> Option<u64> {
self.validity.period()
}
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -542,19 +545,33 @@ impl App {
return None; return None;
} }
let viewer = self.viewer.as_ref()?; let viewer = self.viewer.as_ref()?;
let field = viewer.focused_field()?;
let otp = field.metadata().otp()?;
if otp.kind() != ironstorage::otp::OtpKind::Totp {
return None;
}
let entry = self.selected_entry.clone()?; let entry = self.selected_entry.clone()?;
if self.otp_display.as_ref().is_some_and(|display| { let field = if let Some(display) = self
display.entry == entry .otp_display
&& display.field == Some(field.id()) .as_ref()
&& display.remaining_seconds != Some(0) .filter(|display| display.entry == entry)
}) { {
return None; match display.remaining_seconds {
} Some(0) => {
let field_id = display.field?;
viewer.document().fields().iter().find(|field| {
field.id() == field_id
&& field
.metadata()
.otp()
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
})?
}
Some(_) | None => return None,
}
} else {
viewer.document().fields().iter().find(|field| {
field
.metadata()
.otp()
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
})?
};
self.otp_pending = true; self.otp_pending = true;
Some((entry, field.id())) Some((entry, field.id()))
} }
@@ -998,13 +1015,11 @@ impl App {
Action::FocusNext if self.mode == Mode::Viewer => { Action::FocusNext if self.mode == Mode::Viewer => {
if let Some(viewer) = self.viewer.as_mut() { if let Some(viewer) = self.viewer.as_mut() {
viewer.focus_next(); viewer.focus_next();
self.otp_display = None;
} }
} }
Action::FocusPrevious if self.mode == Mode::Viewer => { Action::FocusPrevious if self.mode == Mode::Viewer => {
if let Some(viewer) = self.viewer.as_mut() { if let Some(viewer) = self.viewer.as_mut() {
viewer.focus_previous(); viewer.focus_previous();
self.otp_display = None;
} }
} }
Action::FocusNext if self.mode == Mode::Editor => { Action::FocusNext if self.mode == Mode::Editor => {

View File

@@ -2,11 +2,12 @@
use ratatui::{ use ratatui::{
Frame, Frame,
layout::{Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap}, widgets::{Block, Borders, Paragraph, Wrap},
}; };
use zeroize::Zeroizing;
use crate::{ use crate::{
action::{context_actions, help_actions}, action::{context_actions, help_actions},
@@ -17,6 +18,8 @@ use crate::{
const MINIMUM_WIDTH: u16 = 40; const MINIMUM_WIDTH: u16 = 40;
const MINIMUM_HEIGHT: u16 = 8; const MINIMUM_HEIGHT: u16 = 8;
const LARGE_OTP_HEIGHT: u16 = 8;
const LARGE_OTP_ROWS: usize = 5;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColorCapability { pub enum ColorCapability {
@@ -69,6 +72,9 @@ pub fn draw_with_color_capability(frame: &mut Frame, app: &App, capability: Colo
draw_inner(frame, app); draw_inner(frame, app);
if capability == ColorCapability::Monochrome { if capability == ColorCapability::Monochrome {
for cell in &mut frame.buffer_mut().content { for cell in &mut frame.buffer_mut().content {
if cell.fg == Color::White && cell.bg == Color::Blue {
cell.modifier.insert(Modifier::REVERSED);
}
cell.set_fg(Color::Reset).set_bg(Color::Reset); cell.set_fg(Color::Reset).set_bg(Color::Reset);
} }
} }
@@ -232,6 +238,56 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
panes[0], panes[0],
); );
if app.mode() == Mode::Viewer
&& let Some(viewer) = app.viewer()
&& large_otp_fits(app, viewer, panes[1])
{
let main_rows =
Layout::vertical([Constraint::Length(LARGE_OTP_HEIGHT), Constraint::Min(3)])
.split(panes[1]);
render_large_otp(
frame,
app.otp_display().expect("large OTP was checked"),
main_rows[0],
);
frame.render_widget(
Paragraph::new(viewer_lines(viewer, None))
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
))
.wrap(Wrap { trim: false }),
main_rows[1],
);
return;
}
if app.mode() == Mode::Viewer
&& let Some(viewer) = app.viewer()
&& let Some(display) = matching_totp_display(app, viewer)
{
let (otp_area, details_area) = if panes[1].height >= 6 {
let rows =
Layout::vertical([Constraint::Length(3), Constraint::Min(3)]).split(panes[1]);
(rows[0], Some(rows[1]))
} else {
(panes[1], None)
};
render_compact_otp(frame, display, otp_area);
if let Some(details_area) = details_area {
frame.render_widget(
Paragraph::new(viewer_lines(viewer, None))
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
))
.wrap(Wrap { trim: false }),
details_area,
);
}
return;
}
let main = match app.mode() { let main = match app.mode() {
Mode::Viewer => app.viewer().map_or_else( Mode::Viewer => app.viewer().map_or_else(
|| Paragraph::new(main_text(app)), || Paragraph::new(main_text(app)),
@@ -267,6 +323,146 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
); );
} }
fn large_otp_fits(app: &App, viewer: &EntryViewer, area: Rect) -> bool {
let Some(display) = matching_totp_display(app, viewer) else {
return false;
};
let code = display.code().expose();
let glyph_width = code.len().saturating_mul(4).saturating_sub(1);
!code.is_empty()
&& code.iter().all(u8::is_ascii_digit)
&& usize::from(area.width.saturating_sub(2)) >= glyph_width
&& area.height >= LARGE_OTP_HEIGHT.saturating_add(3)
}
fn matching_totp_display<'a>(
app: &'a App,
viewer: &EntryViewer,
) -> Option<&'a crate::app::OtpDisplay> {
let display = app.otp_display()?;
if app.selected_entry() != Some(display.entry())
|| display.remaining_seconds().is_none()
|| display.period_seconds().is_none()
{
return None;
}
let field_id = display.field()?;
viewer
.document()
.fields()
.iter()
.any(|field| {
field.id() == field_id
&& field
.metadata()
.otp()
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
})
.then_some(display)
}
fn render_large_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: Rect) {
let glyphs = (0..LARGE_OTP_ROWS)
.map(|row| otp_ascii_row(display.code().expose(), row))
.collect::<Vec<_>>();
let remaining = display.remaining_seconds().unwrap_or_default();
let period = display.period_seconds().unwrap_or(1);
let inner_width = usize::from(area.width.saturating_sub(2));
let mut lines = glyphs
.iter()
.map(|row| {
Line::styled(
row.as_str(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
})
.collect::<Vec<_>>();
lines.push(Line::styled(
countdown_bar(remaining, period, inner_width),
Style::default().fg(Color::Cyan),
));
frame.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Center)
.block(Block::bordered().title(format!("TOTP code — {remaining}s remaining"))),
area,
);
}
fn render_compact_otp(frame: &mut Frame, display: &crate::app::OtpDisplay, area: Rect) {
let code = String::from_utf8_lossy(display.code().expose());
let remaining = display.remaining_seconds().unwrap_or_default();
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
code.as_ref(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!("{remaining}s remaining")),
]))
.alignment(Alignment::Center)
.block(Block::bordered().title("TOTP code")),
area,
);
}
fn otp_ascii_row(code: &[u8], row: usize) -> Zeroizing<String> {
let mut rendered = Zeroizing::new(String::with_capacity(
code.len().saturating_mul(4).saturating_sub(1),
));
for (index, digit) in code.iter().copied().enumerate() {
if index != 0 {
rendered.push(' ');
}
rendered.push_str(otp_digit_glyph(digit, row));
}
rendered
}
fn otp_digit_glyph(digit: u8, row: usize) -> &'static str {
const GLYPHS: [[&str; LARGE_OTP_ROWS]; 10] = [
["000", "0 0", "0 0", "0 0", "000"],
[" 1 ", "11 ", " 1 ", " 1 ", "111"],
["222", " 2", "222", "2 ", "222"],
["333", " 3", "333", " 3", "333"],
["4 4", "4 4", "444", " 4", " 4"],
["555", "5 ", "555", " 5", "555"],
["666", "6 ", "666", "6 6", "666"],
["777", " 7", " 7 ", " 7 ", " 7 "],
["888", "8 8", "888", "8 8", "888"],
["999", "9 9", "999", " 9", "999"],
];
let index = usize::from(digit.saturating_sub(b'0'));
GLYPHS
.get(index)
.and_then(|glyph| glyph.get(row))
.copied()
.unwrap_or(" ")
}
fn countdown_bar(remaining: u64, period: u64, width: usize) -> String {
let label = format!("{remaining}s");
let bar_width = width.saturating_sub(label.len().saturating_add(1));
let period = period.max(1);
let remaining = remaining.min(period);
let filled = if remaining == 0 {
0
} else {
usize::try_from((u128::from(remaining) * bar_width as u128).div_ceil(u128::from(period)))
.unwrap_or(bar_width)
.min(bar_width)
};
format!(
"{}{} {label}",
"".repeat(filled),
"".repeat(bar_width.saturating_sub(filled))
)
}
fn pane_block(title: &'static str, focused: bool) -> Block<'static> { fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
let style = if focused { let style = if focused {
Style::default() Style::default()
@@ -281,6 +477,14 @@ fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
.border_style(style) .border_style(style)
} }
fn selected_line<'a>(mut spans: Vec<Span<'a>>) -> Line<'a> {
let style = Style::default().bg(Color::Blue).fg(Color::White);
for span in &mut spans {
span.style = span.style.bg(Color::Blue).fg(Color::White);
}
Line::from(spans).style(style)
}
fn sidebar_lines(app: &App) -> Vec<Line<'static>> { fn sidebar_lines(app: &App) -> Vec<Line<'static>> {
let selected = app.sidebar().selected(); let selected = app.sidebar().selected();
let rows = app.sidebar().visible_window(); let rows = app.sidebar().visible_window();
@@ -408,8 +612,7 @@ fn viewer_lines<'a>(
), ),
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
if focused == Some(index) if let Some(display) = otp_display
&& let Some(display) = otp_display
&& display && display
.field() .field()
.is_none_or(|display_field| display_field == field.id()) .is_none_or(|display_field| display_field == field.id())
@@ -423,19 +626,21 @@ fn viewer_lines<'a>(
}, },
|remaining| format!(", {remaining}s remaining"), |remaining| format!(", {remaining}s remaining"),
); );
spans.push(Span::styled( spans.insert(
format!(" code {code}{validity}"), 0,
Style::default() Span::styled(
.fg(Color::Green) format!("code {code}{validity} "),
.add_modifier(Modifier::BOLD), Style::default()
)); .fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
);
} }
} }
let line = Line::from(spans);
if focused == Some(index) { if focused == Some(index) {
line.style(Style::default().bg(Color::Blue).fg(Color::White)) selected_line(spans)
} else { } else {
line Line::from(spans)
} }
}) })
.collect() .collect()
@@ -503,11 +708,10 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
Style::default().fg(Color::Yellow), Style::default().fg(Color::Yellow),
)); ));
} }
let line = Line::from(spans);
if selected { if selected {
line.style(Style::default().bg(Color::Blue).fg(Color::White)) selected_line(spans)
} else { } else {
line Line::from(spans)
} }
}) })
.collect() .collect()
@@ -753,6 +957,14 @@ mod tests {
.iter() .iter()
.all(|cell| { cell.fg == Color::Reset && cell.bg == Color::Reset }) .all(|cell| { cell.fg == Color::Reset && cell.bg == Color::Reset })
); );
assert!(
terminal
.backend()
.buffer()
.content
.iter()
.any(|cell| cell.modifier.contains(Modifier::REVERSED))
);
terminal terminal
.draw(|frame| draw_with_color_capability(frame, &app, ColorCapability::Color)) .draw(|frame| draw_with_color_capability(frame, &app, ColorCapability::Color))
@@ -1020,7 +1232,10 @@ mod tests {
.expect("OTP field") .expect("OTP field")
.id(); .id();
app.open_test_document("otp/totp", document); app.open_test_document("otp/totp", document);
app.dispatch(crate::action::Action::FocusNext); assert_eq!(
app.begin_totp_refresh(),
Some(("otp/totp".to_owned(), otp_field))
);
let token = app.begin_request(); let token = app.begin_request();
app.apply_result(crate::app::AsyncResult { app.apply_result(crate::app::AsyncResult {
token, token,
@@ -1028,13 +1243,17 @@ mod tests {
entry: "otp/totp".to_owned(), entry: "otp/totp".to_owned(),
field: Some(wrong_field), field: Some(wrong_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()), code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 }, validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 72,
period: 30,
},
observed_at: 60, observed_at: 60,
clipboard: false, clipboard: false,
tree: None, tree: None,
}), }),
}); });
assert!(!render(120, 20, &app).contains("123456")); assert!(!render(120, 20, &app).contains("123456"));
app.dispatch(crate::action::Action::FocusNext);
let token = app.begin_request(); let token = app.begin_request();
app.apply_result(crate::app::AsyncResult { app.apply_result(crate::app::AsyncResult {
token, token,
@@ -1042,19 +1261,38 @@ mod tests {
entry: "otp/totp".to_owned(), entry: "otp/totp".to_owned(),
field: Some(otp_field), field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()), code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 72 }, validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 72,
period: 30,
},
observed_at: 60, observed_at: 60,
clipboard: false, clipboard: false,
tree: None, tree: None,
}), }),
}); });
let code = render(120, 20, &app); app.dispatch(crate::action::Action::FocusPrevious);
assert!(code.contains("123456")); let large = render(120, 20, &app);
assert!(code.contains("12s remaining")); assert!(large.contains("TOTP code — 12s remaining"));
assert!(!code.contains("JBSWY3DPEHPK3PXP")); assert!(large.contains("222 333"));
assert!(!large.contains("code 123456"));
assert!(!large.contains("JBSWY3DPEHPK3PXP"));
let full_bar = large.matches('█').count();
let compact = render(60, 16, &app);
assert!(compact.contains("123456 — 12s remaining"));
assert!(!compact.contains("TOTP code —"));
app.observe_time(66);
let progressed = render(120, 20, &app);
assert!(progressed.contains("TOTP code — 6s remaining"));
let progressed_bar = progressed.matches('█').count();
assert!(progressed_bar < full_bar);
assert!(progressed_bar > 0);
app.observe_time(72); app.observe_time(72);
assert!(render(120, 20, &app).contains("0s remaining")); let expired = render(120, 20, &app);
assert!(expired.contains("0s remaining"));
assert_eq!(expired.matches('█').count(), 0);
assert_eq!( assert_eq!(
app.begin_totp_refresh(), app.begin_totp_refresh(),
Some(("otp/totp".to_owned(), otp_field)) Some(("otp/totp".to_owned(), otp_field))
@@ -1066,16 +1304,23 @@ mod tests {
entry: "otp/totp".to_owned(), entry: "otp/totp".to_owned(),
field: Some(otp_field), field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"654321".to_vec()), code: ironstorage::repository::SecretBytes::new(b"654321".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed { valid_until: 102 }, validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 102,
period: 30,
},
observed_at: 72, observed_at: 72,
clipboard: false, clipboard: false,
tree: None, tree: None,
}), }),
}); });
let refreshed = render(120, 20, &app); let refreshed = render(120, 20, &app);
assert!(refreshed.contains("654321")); assert!(refreshed.contains("TOTP code — 30s remaining"));
assert!(refreshed.contains("30s remaining")); assert!(refreshed.contains("666 555"));
assert!(!refreshed.contains("123456")); assert!(refreshed.contains("333 222"));
assert!(!refreshed.contains("222 333"));
assert!(refreshed.matches('█').count() > full_bar);
let minimum = render(40, 8, &app);
assert!(minimum.contains("654321 — 30s remaining"));
let payload = ironstorage::repository::SecretBytes::new( let payload = ironstorage::repository::SecretBytes::new(
b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(), b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(),
@@ -1100,9 +1345,87 @@ mod tests {
)); ));
let locked = render(120, 20, &app); let locked = render(120, 20, &app);
assert!(!locked.contains("123456")); assert!(!locked.contains("123456"));
assert!(!locked.contains("666 555"));
assert!(!locked.contains("NEVER-RENDER")); assert!(!locked.contains("NEVER-RENDER"));
} }
#[test]
fn selected_viewer_spans_override_nested_colors_for_readable_contrast() {
let mut app = App::new();
let document = fixture_document("otp/totp");
let otp_field = document
.fields()
.iter()
.find(|field| field.metadata().otp().is_some())
.expect("OTP field")
.id();
app.open_test_document("otp/totp", document);
while app
.viewer()
.and_then(EntryViewer::focused_field)
.map(|field| field.id())
!= Some(otp_field)
{
app.dispatch(crate::action::Action::FocusNext);
}
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
entry: "otp/totp".to_owned(),
field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 90,
period: 30,
},
observed_at: 60,
clipboard: false,
tree: None,
}),
});
let viewer = app.viewer().expect("viewer");
let lines = viewer_lines(viewer, app.otp_display());
let selected = &lines[viewer.focused_index().expect("focused field")];
assert!(
selected
.spans
.iter()
.any(|span| span.content.contains("code 123456"))
);
assert!(selected.spans.iter().all(|span| {
span.style.fg == Some(Color::White) && span.style.bg == Some(Color::Blue)
}));
let editor = EntryEditor::new(fixture_document("email/personal"));
let editor_selected = &editor_lines(&editor)[0];
assert!(editor_selected.spans.iter().all(|span| {
span.style.fg == Some(Color::White) && span.style.bg == Some(Color::Blue)
}));
let backend = TestBackend::new(60, 16);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|frame| {
draw_with_color_capability(frame, &app, ColorCapability::Monochrome);
})
.expect("monochrome viewer draw");
let buffer = terminal.backend().buffer();
assert!(
buffer
.content
.iter()
.any(|cell| cell.modifier.contains(Modifier::REVERSED))
);
assert!(
buffer
.content
.iter()
.all(|cell| cell.fg == Color::Reset && cell.bg == Color::Reset)
);
}
#[test] #[test]
fn closing_a_document_preserves_the_sidebar_selection() { fn closing_a_document_preserves_the_sidebar_selection() {
let mut app = App::new(); let mut app = App::new();

View File

@@ -433,17 +433,18 @@ pub struct OtpCodeOutcome {
/// ///
/// Frontends use this value instead of deriving TOTP periods or inferring HOTP /// Frontends use this value instead of deriving TOTP periods or inferring HOTP
/// behavior from display strings. A timed code carries its exclusive Unix-time /// behavior from display strings. A timed code carries its exclusive Unix-time
/// boundary, while a counter-based code identifies the committed HOTP counter. /// boundary and complete period for progress presentation, while a
/// counter-based code identifies the committed HOTP counter.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OtpCodeValidity { pub enum OtpCodeValidity {
Timed { valid_until: u64 }, Timed { valid_until: u64, period: u64 },
CounterBased { counter: u64 }, CounterBased { counter: u64 },
} }
impl OtpCodeValidity { impl OtpCodeValidity {
pub fn valid_until(self) -> Option<u64> { pub fn valid_until(self) -> Option<u64> {
match self { match self {
Self::Timed { valid_until } => Some(valid_until), Self::Timed { valid_until, .. } => Some(valid_until),
Self::CounterBased { .. } => None, Self::CounterBased { .. } => None,
} }
} }
@@ -455,6 +456,13 @@ impl OtpCodeValidity {
} }
} }
pub fn period(self) -> Option<u64> {
match self {
Self::Timed { period, .. } => Some(period),
Self::CounterBased { .. } => None,
}
}
pub fn remaining_at(self, unix_seconds: u64) -> Option<u64> { pub fn remaining_at(self, unix_seconds: u64) -> Option<u64> {
self.valid_until() self.valid_until()
.map(|valid_until| valid_until.saturating_sub(unix_seconds)) .map(|valid_until| valid_until.saturating_sub(unix_seconds))
@@ -695,6 +703,7 @@ impl<'a> OtpService<'a> {
.checked_add(1) .checked_add(1)
.and_then(|counter| counter.checked_mul(period)) .and_then(|counter| counter.checked_mul(period))
.ok_or(OtpError::CounterOverflow)?, .ok_or(OtpError::CounterOverflow)?,
period,
}, },
}); });
} }
@@ -743,18 +752,21 @@ impl<'a> OtpService<'a> {
committer: &mut impl EntryCommitter, committer: &mut impl EntryCommitter,
) -> Result<OtpCodeOutcome, OtpError> { ) -> Result<OtpCodeOutcome, OtpError> {
match uri.kind() { match uri.kind() {
OtpKind::Totp => Ok(OtpCodeOutcome { OtpKind::Totp => {
code: uri.code_at(unix_seconds)?, let period = uri.period().ok_or(OtpError::NotTotp)?;
validity: OtpCodeValidity::Timed { Ok(OtpCodeOutcome {
valid_until: { code: uri.code_at(unix_seconds)?,
let period = uri.period().ok_or(OtpError::NotTotp)?; validity: OtpCodeValidity::Timed {
(unix_seconds / period) valid_until: {
.checked_add(1) (unix_seconds / period)
.and_then(|counter| counter.checked_mul(period)) .checked_add(1)
.ok_or(OtpError::CounterOverflow)? .and_then(|counter| counter.checked_mul(period))
.ok_or(OtpError::CounterOverflow)?
},
period,
}, },
}, })
}), }
OtpKind::Hotp => { OtpKind::Hotp => {
let (counter, incremented) = uri.incremented_hotp()?; let (counter, incremented) = uri.incremented_hotp()?;
let code = incremented.code_for_counter(counter)?; let code = incremented.code_for_counter(counter)?;

View File

@@ -478,7 +478,14 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes
let totp = service.code_automatic("otp/totp", 59, None, &mut provider)?; let totp = service.code_automatic("otp/totp", 59, None, &mut provider)?;
assert_eq!(totp.counter(), None); assert_eq!(totp.counter(), None);
assert_eq!(totp.valid_until(), Some(60)); assert_eq!(totp.valid_until(), Some(60));
assert_eq!(totp.validity(), OtpCodeValidity::Timed { valid_until: 60 }); assert_eq!(
totp.validity(),
OtpCodeValidity::Timed {
valid_until: 60,
period: 30,
}
);
assert_eq!(totp.validity().period(), Some(30));
assert_eq!(totp.remaining_at(59), Some(1)); assert_eq!(totp.remaining_at(59), Some(1));
assert_eq!(totp.remaining_at(60), Some(0)); assert_eq!(totp.remaining_at(60), Some(0));
assert_eq!(repository.read_entry(&totp_path)?, totp_before); assert_eq!(repository.read_entry(&totp_path)?, totp_before);
@@ -494,6 +501,7 @@ fn automatic_code_supports_pass_diff_config_and_opens_git_only_for_hotp() -> Tes
OtpCodeValidity::CounterBased { counter: 1 } OtpCodeValidity::CounterBased { counter: 1 }
); );
assert_eq!(hotp.remaining_at(0), None); assert_eq!(hotp.remaining_at(0), None);
assert_eq!(hotp.validity().period(), None);
assert_eq!(service.uri("otp/hotp", &mut provider)?.counter(), Some(1)); assert_eq!(service.uri("otp/hotp", &mut provider)?.counter(), Some(1));
let git = GitRepository::open(&repository, identity)?; let git = GitRepository::open(&repository, identity)?;
assert_eq!(git.log(None)?.len(), initial_commits + 1); assert_eq!(git.log(None)?.len(), initial_commits + 1);

View File

@@ -50,13 +50,14 @@ supports terminal, clipboard, and the shared storage-owned QR matrix renderer.
Clipboard and QR requests never print the underlying code or URI as plaintext. Clipboard and QR requests never print the underlying code or URI as plaintext.
Every generated code carries an `OtpCodeValidity` value from `crates/storage`. Every generated code carries an `OtpCodeValidity` value from `crates/storage`.
`Timed { valid_until }` identifies the exclusive Unix-time boundary for TOTP; `Timed { valid_until, period }` identifies the exclusive Unix-time boundary and
frontends call its `remaining_at` method to present a countdown and request a complete display interval for TOTP; frontends call its `remaining_at` method to
replacement at zero. `CounterBased { counter }` identifies the HOTP counter present a countdown and request a replacement at zero. The `CounterBased`
whose increment was committed and must be described as counter-based rather variant identifies the HOTP counter whose increment was committed and must be
than time-limited. Frontends must not recover periods from OTP URIs, decrement described as counter-based rather than time-limited. Frontends must not recover
an assumed interval, or infer the kind from formatted text. This same contract periods from OTP URIs, decrement an assumed interval, or infer the kind from
is intended for the terminal, desktop, Apple, AutoFill, and watch interfaces. formatted text. This same contract is intended for the terminal, desktop,
Apple, AutoFill, and watch interfaces.
The CLI preserves code-only standard output for pass-compatible pipelines and The CLI preserves code-only standard output for pass-compatible pipelines and
reports the non-secret validity description on standard error. Clipboard reports the non-secret validity description on standard error. Clipboard