Compare commits
4 commits
f7054fdd39
...
7a70f34311
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a70f34311 | |||
| 377608f78d | |||
| a6aa81882d | |||
| 44aa00872a |
2 changed files with 88 additions and 20 deletions
28
README.md
28
README.md
|
|
@ -35,6 +35,30 @@ otp-command = "echo 'otpauth://totp/example?algorithm=SHA1&digits=6&secret=IHACD
|
|||
|
||||
If the command is a string, it is interpreted with the shell; otherwise, the list of arguments is used directly.
|
||||
|
||||
## Auto-exit on idle
|
||||
|
||||
To auto exit after a specified inactivity period, use the `auto-exit` setting:
|
||||
```toml
|
||||
# Exit after 5 minutes (300 seconds) of inactivity
|
||||
auto-exit = 300
|
||||
```
|
||||
Any key event, mouse click, or mouse scroll counts as "activity" and will reset the auto exit timer.
|
||||
|
||||
If `auto-exit` is not specified, or it is 0, there is no inactivity timeout.
|
||||
|
||||
## Profiles
|
||||
|
||||
textual-totp supports multiple profiles. Profiles are organized as sections of the configuration file; if a setting is not specified within a profile section, the global setting is used.
|
||||
|
||||
For example, given
|
||||
```
|
||||
auto-exit=300
|
||||
otp-command = ["..."]
|
||||
[trusted-location]
|
||||
auto-exit=0
|
||||
```
|
||||
textual-totp will normally exit after 5 minutes of inactivity, but when you run `ttotp --profile trusted-location` auto-exit will be disabled.
|
||||
|
||||
# Obtaining TOTP URIs
|
||||
|
||||
There are a couple of ways to obtain your TOTP URIs, which are strings that begin `otpauth://totp/`.
|
||||
|
|
@ -75,10 +99,10 @@ Your Operating System may report that `ttotp` "pasted from the clipboard".
|
|||
This is because `ttotp` tries to only clear values that it set,
|
||||
by checking that the current clipboard value is equal to the value it pasted earlier.
|
||||
|
||||
Search for a key by pressing "/" and then entering a modified case insensitive regular expression.
|
||||
Search for a key by pressing "/" and then entering sub-strings to search for.
|
||||
Press Ctrl+A to show all keys again.
|
||||
|
||||
In this type of regular expression, a space ` ` stands for "zero or more characters, followed by whitespace, followed by zero or more characters"; the sequence backslash-space stands for a literal space.
|
||||
Textual's built in [fuzzy match](https://textual.textualize.io/api/fuzzy_matcher/) algorithm is used.
|
||||
|
||||
This makes it easy to search for e.g., "Jay Doe / example.com" by entering "ja d ex", while not requiring any sophisticated fuzzy search technology.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ import re
|
|||
from typing import TYPE_CHECKING, Any, Sequence, cast
|
||||
|
||||
import rich.text
|
||||
from textual.fuzzy import Matcher
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.events import Key, MouseDown, MouseUp, MouseScrollDown, MouseScrollUp
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Label, Footer, ProgressBar, Button, Input
|
||||
from textual.binding import Binding
|
||||
|
|
@ -278,12 +280,17 @@ class TTOTP(App[None]):
|
|||
Binding("ctrl+a", "clear_search", "Show all", show=True),
|
||||
]
|
||||
|
||||
def __init__(self, tokens: Sequence[pyotp.TOTP]) -> None:
|
||||
def __init__(
|
||||
self, tokens: Sequence[pyotp.TOTP], timeout: int | float | None
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.tokens = tokens
|
||||
self.otp_data: list[TOTPData] = []
|
||||
self.timer: Timer | None = None
|
||||
self.clear_clipboard_time: Timer | None = None
|
||||
self.exit_time: Timer | None = None
|
||||
self.warn_exit_time: Timer | None = None
|
||||
self.timeout: int | float | None = timeout
|
||||
self.copied = ""
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
|
@ -292,6 +299,24 @@ class TTOTP(App[None]):
|
|||
self.clear_clipboard_timer = self.set_timer(
|
||||
30, self.clear_clipboard_func, pause=True
|
||||
)
|
||||
if self.timeout:
|
||||
self.exit_time = self.set_timer(self.timeout, self.action_quit)
|
||||
warn_timeout = max(self.timeout / 2, self.timeout - 10)
|
||||
self.warn_exit_time = self.set_timer(warn_timeout, self.warn_quit)
|
||||
|
||||
def reset_exit_timers(self) -> None:
|
||||
if self.exit_time:
|
||||
self.exit_time.reset()
|
||||
if self.warn_exit_time:
|
||||
self.warn_exit_time.reset()
|
||||
|
||||
async def on_event(self, event: Any) -> None:
|
||||
if isinstance(event, (Key, MouseDown, MouseUp, MouseScrollDown, MouseScrollUp)):
|
||||
self.reset_exit_timers()
|
||||
await super().on_event(event)
|
||||
|
||||
def warn_quit(self) -> None:
|
||||
self.notify("Will exit soon due to inactivity", title="Auto-exit")
|
||||
|
||||
def clear_clipboard_func(self) -> None:
|
||||
if pyperclip_paste() == self.copied:
|
||||
|
|
@ -354,20 +379,33 @@ class TTOTP(App[None]):
|
|||
self.screen.focus_next()
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
haystack = event.value.replace(" ", ".* .*")
|
||||
try:
|
||||
rx = re.compile(haystack, re.I)
|
||||
except re.error:
|
||||
self.search.add_class("error")
|
||||
return
|
||||
self.search.remove_class("error")
|
||||
for otp in self.otp_data:
|
||||
parent = otp.name_widget.parent
|
||||
assert parent is not None
|
||||
if rx.search(otp.name):
|
||||
parent.remove_class("otp-hidden")
|
||||
else:
|
||||
parent.add_class("otp-hidden")
|
||||
with self.batch_update():
|
||||
needle = event.value
|
||||
if not needle:
|
||||
for otp in self.otp_data:
|
||||
name_widget = otp.name_widget
|
||||
parent = name_widget.parent
|
||||
assert parent is not None
|
||||
parent.remove_class("otp-hidden")
|
||||
name_widget.update(
|
||||
rich.text.Text(otp.name, overflow="ellipsis", no_wrap=True)
|
||||
)
|
||||
return
|
||||
|
||||
matcher = Matcher(needle)
|
||||
for otp in self.otp_data:
|
||||
name_widget = otp.name_widget
|
||||
parent = name_widget.parent
|
||||
assert parent is not None
|
||||
score = matcher.match(otp.name)
|
||||
if score > 0:
|
||||
highlighted = matcher.highlight(otp.name)
|
||||
highlighted.overflow = "ellipsis"
|
||||
highlighted.no_wrap = True
|
||||
name_widget.update(highlighted)
|
||||
parent.remove_class("otp-hidden")
|
||||
else:
|
||||
parent.add_class("otp-hidden")
|
||||
|
||||
def on_input_submitted(self, event: Input.Changed) -> None:
|
||||
self.screen.focus_next()
|
||||
|
|
@ -418,9 +456,10 @@ multiple profiles as configuration file sections, and select one with
|
|||
config_data = tomllib.load(f)
|
||||
|
||||
if profile:
|
||||
config_data = config_data.get(profile, None)
|
||||
if config_data is None:
|
||||
profile_data = config_data.get(profile, None)
|
||||
if profile_data is None:
|
||||
config_hint(f"The profile {profile!r} file does not exist.")
|
||||
config_data.update(profile_data)
|
||||
|
||||
otp_command = config_data.get("otp-command")
|
||||
if otp_command is None:
|
||||
|
|
@ -433,6 +472,11 @@ multiple profiles as configuration file sections, and select one with
|
|||
else:
|
||||
config_hint("The otp-command value must be a string or list of strings.")
|
||||
|
||||
timeout = config_data.get("auto-exit", None)
|
||||
if timeout is not None:
|
||||
if not isinstance(timeout, (float, int)):
|
||||
config_hint("If specified, the auto-exit value must be a number.")
|
||||
|
||||
tokens: list[pyotp.TOTP] = []
|
||||
for row in c.strip().split("\n"):
|
||||
if row.startswith("otpauth://"):
|
||||
|
|
@ -441,7 +485,7 @@ multiple profiles as configuration file sections, and select one with
|
|||
if not tokens:
|
||||
config_hint("No tokens were found when running the given command.")
|
||||
|
||||
TTOTP(tokens).run()
|
||||
TTOTP(tokens, timeout).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Reference in a new issue