Compare commits

...

4 commits

Author SHA1 Message Date
7a70f34311
Merge pull request #10 from jepler/add-exit-timeout
Add auto-exit after inactivity
2023-12-16 15:19:29 -06:00
377608f78d
Add auto-exit after inactivity
Closes: #6
2023-12-16 13:52:47 -06:00
a6aa81882d
Merge pull request #9 from jepler/fuzzy
Switch to textual's own fuzzzy matcher
2023-12-16 13:10:33 -06:00
44aa00872a
Switch to textual's own fuzzzy matcher
Closes #8
2023-12-16 11:53:22 -06:00
2 changed files with 88 additions and 20 deletions

View file

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

View file

@ -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__":