Improve TUI OTP readability
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
15
docs/otp.md
15
docs/otp.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user