Compare commits

...

13 commits

Author SHA1 Message Date
e046b62a93
set update interval smaller 2023-12-04 21:23:14 -06:00
938c652053
Refine the README 2023-12-04 19:32:26 -06:00
1784ede3a3
finish converting to horizontal layout. improve interactions. 2023-12-04 19:24:03 -06:00
556b997ae4
fix linter messages 2023-12-04 19:10:06 -06:00
cdac5f90e5
re-organize and add copy/show clickable buttons 2023-12-04 19:07:21 -06:00
d156e0e2ad
improve error message 2023-12-04 18:43:12 -06:00
d797bc1d24
refactor a bit
I'm not entirely satisfied with how the widgets & data are related,
but this seems like an improvement.
2023-12-04 16:16:56 -06:00
76648063dd
can install via git 2023-12-04 09:21:10 -06:00
4c670cc286
Make it installable 2023-12-04 09:06:29 -06:00
7767fa4b66
License as MIT 2023-12-04 08:36:42 -06:00
32c5240fea
support multiple profiles 2023-12-04 08:35:11 -06:00
5b1f9ac2e8
put a space between the longest OTP code & the label 2023-12-04 08:34:59 -06:00
6ba5fef1fc
Support up/down to cycle among TOTPs 2023-12-04 08:34:33 -06:00
11 changed files with 544 additions and 270 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2023 Jeff Epler
#
# SPDX-License-Identifier: MIT
/venv
*.egg-info/
__version__.py

34
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,34 @@
# SPDX-FileCopyrightText: 2020 Diego Elio Pettenò
#
# SPDX-License-Identifier: Unlicense
default_language_version:
python: python3
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
exclude: tests
- id: trailing-whitespace
exclude: tests
- repo: https://github.com/codespell-project/codespell
rev: v2.2.6
hooks:
- id: codespell
args: [-w]
- repo: https://github.com/fsfe/reuse-tool
rev: v2.1.0
hooks:
- id: reuse
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.1.6
hooks:
# Run the linter.
- id: ruff
args: [ --fix, --preview ]
# Run the formatter.
- id: ruff-format

9
LICENSES/MIT.txt Normal file
View file

@ -0,0 +1,9 @@
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

10
LICENSES/Unlicense.txt Normal file
View file

@ -0,0 +1,10 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

16
Makefile Normal file
View file

@ -0,0 +1,16 @@
# SPDX-FileCopyrightText: 2023 Jeff Epler <jepler@gmail.com>
#
# SPDX-License-Identifier: MIT
.PHONY: mypy
mypy: venv/bin/mypy
venv/bin/mypy
# Update CONTRIBUTING.md if these commands change
venv/bin/mypy:
python -mvenv venv
venv/bin/pip install -r requirements.txt 'mypy!=1.7.0'
.PHONY: clean
clean:
rm -rf venv

View file

@ -1,9 +1,15 @@
# textual-totp: TOTP (authenticator) application using Python & Textual
<!--
SPDX-FileCopyrightText: 2023 Jeff Epler
SPDX-License-Identifier: MIT
-->
# textual-totp: TOTP (authenticator) application using Python & Textual
# Installation
Right now, you have to `pip install` the requirements and then run the program with
`python ttotp.py`
Right now you have to pick this up from github, it's not yet on pypi:
`pipx install https://github.com/jepler/textual-totp@main`
# Configuration
@ -25,22 +31,36 @@ 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.
# Using the app
# Using textual-totp
Once the app has started, it will show each available TOTP. The code will show as "\*\*\*\*\*\*" until it is revealed.
To reveal a code, tab to the desired line and press "s".
When the code expires, it will be replaced with "\*\*\*\*\*\*" again.
The command to start textual-totp is `ttotp`.
It has several options which can be shown with `ttotp --help`.
You can also copy a code directly to the operating system's clipboard by pressing "c".
`ttotp` will first invoke the `otp-command` to get the list of TOTPs.
This may require interaction
(for instance, the `pass` command may need to request your GPG key passphrase)
Once the otp-command finishes, `ttotp` will show each available TOTP.
Each code will show as `******` until it is revealed.
Navigate up/down in several ways:
* up and down keys
* tab and shift-tab keys
* "j" and "k" (vi keys)
To reveal a code, move to the desired line and press "s".
When the code expires, it will be replaced with `******` again.
Copy a code directly to the operating system's clipboard by pressing "c".
The code will be cleared from the clipboard after 30 seconds.
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.
You can exit the app with Ctrl+C.
Exit the app with Ctrl+C.
# In-memory storage of TOTPs
As long as `ttotp` is open, the TOTP secret values are stored in memory in plain text.
`ttotp` never writes secret values to operating system files or store them in environment variables.
`ttotp` never writes secret values to operating system files or stores them in environment variables.
(but your otp-command might! check any related documentation carefully)

53
pyproject.toml Normal file
View file

@ -0,0 +1,53 @@
# SPDX-FileCopyrightText: 2021 Jeff Epler
#
# SPDX-License-Identifier: MIT
[build-system]
requires = [
"setuptools>=68.2.2",
"setuptools_scm[toml]>=6.0",
]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[project]
name="textual-totp"
authors = [{name = "Jeff Epler", email = "jepler@gmail.com"}]
description = "TOTP (authenticator) application using Python & Textual"
dynamic = ["readme","version","dependencies"]
requires-python = ">=3.11"
keywords = ["otp", "totp", "2fa", "authenticator"]
classifiers = [
"Development Status :: 3 - Alpha",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
]
[project.urls]
homepage = "https://github.com/jepler/textual-totp"
repository = "https://github.com/jepler/textual-totp"
[project.scripts]
ttotp = "ttotp.__main__:main"
[tool.setuptools_scm]
write_to = "src/ttotp/__version__.py"
[tool.setuptools.dynamic]
readme = {file = ["README.md"], content-type="text/markdown"}
dependencies = {file = "requirements.txt"}
[tool.setuptools.package-data]
"pkgname" = ["py.typed"]
[tool.mypy]
mypy_path = ["src"]
warn_unused_ignores = false
warn_redundant_casts = true
strict = true
packages = ["ttotp"]

9
requirements.txt Normal file
View file

@ -0,0 +1,9 @@
# SPDX-FileCopyrightText: 2023 Jeff Epler
#
# SPDX-License-Identifier: MIT
textual
pyperclip
pyotp
click
platformdirs

0
src/ttotp/__init__.py Normal file
View file

376
src/ttotp/__main__.py Executable file
View file

@ -0,0 +1,376 @@
#!/usr/bin/python3
# SPDX-FileCopyrightText: 2023 Jeff Epler
#
# SPDX-License-Identifier: MIT
from dataclasses import dataclass, field
import time
import hashlib
import pathlib
import subprocess
from urllib.parse import parse_qsl, unquote, urlparse
import re
from typing import TYPE_CHECKING, Any, Sequence, cast
from textual.app import App, ComposeResult
from textual.widget import Widget
from textual.widgets import Label, Footer, ProgressBar, Button
from textual.binding import Binding
from textual.containers import VerticalScroll, Horizontal
from textual.css.query import DOMQuery
from textual.timer import Timer
import click
import pyotp
import platformdirs
import tomllib
# workaround for pyperclip being un-typed
if TYPE_CHECKING:
def pyperclip_paste() -> str:
...
def pyperclip_copy(data: str) -> None:
...
else:
from pyperclip import paste as pyperclip_paste
from pyperclip import copy as pyperclip_copy
from typing import TypeGuard # use `typing_extensions` for Python 3.9 and below
def is_str_list(val: Any) -> TypeGuard[list[str]]:
"""Determines whether all objects in the list are strings"""
if not isinstance(val, list):
return False
return all(isinstance(x, str) for x in val)
# Copied from pyotp with the issuer mismatch check removed and HTOP support removed
def parse_uri(uri: str) -> pyotp.TOTP:
"""
Parses the provisioning URI for the TOTP
See also:
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
:param uri: the hotp/totp URI to parse
:returns: OTP object
"""
# Secret (to be filled in later)
secret = None
# Data we'll parse to the correct constructor
otp_data: dict[str, Any] = {}
# Parse with URLlib
parsed_uri = urlparse(unquote(uri))
if parsed_uri.scheme != "otpauth":
raise ValueError("Not an otpauth URI")
# Parse issuer/accountname info
accountinfo_parts = re.split(":|%3A", parsed_uri.path[1:], maxsplit=1)
if len(accountinfo_parts) == 1:
otp_data["name"] = accountinfo_parts[0]
else:
otp_data["issuer"] = accountinfo_parts[0]
otp_data["name"] = accountinfo_parts[1]
# Parse values
for key, value in parse_qsl(parsed_uri.query):
if key == "secret":
secret = value
elif key == "issuer":
otp_data["issuer"] = value
elif key == "algorithm":
if value == "SHA1":
otp_data["digest"] = hashlib.sha1
elif value == "SHA256":
otp_data["digest"] = hashlib.sha256
elif value == "SHA512":
otp_data["digest"] = hashlib.sha512
else:
raise ValueError(
"Invalid value for algorithm, must be SHA1, SHA256 or SHA512"
)
elif key == "digits":
digits = int(value)
if digits not in [6, 7, 8]:
raise ValueError("Digits may only be 6, 7, or 8")
otp_data["digits"] = digits
elif key == "period":
otp_data["interval"] = int(value)
elif key == "counter":
otp_data["initial_count"] = int(value)
elif key != "image":
raise ValueError("{} is not a valid parameter".format(key))
if not secret:
raise ValueError("No secret found in URI")
# Create objects
if parsed_uri.netloc == "totp":
return pyotp.TOTP(secret, **otp_data)
raise ValueError(f"Not a supported OTP type: {parsed_uri.netloc}")
default_conffile = platformdirs.user_config_path("ttotp") / "settings.toml"
class TOTPLabel(Label, can_focus=True):
otp: "TOTPData"
BINDINGS = [
Binding("c", "copy", "Copy code", show=True),
Binding("s", "show", "Show code", show=True),
Binding("up", "focus_previous", show=False),
Binding("down", "focus_next", show=False),
Binding("k", "focus_previous", show=False),
Binding("j", "focus_next", show=False),
]
def __init__(self, otp: "TOTPData") -> None:
self.otp = otp
super().__init__(
f"{otp.totp.name} / {otp.totp.issuer}",
classes=f"otp-name otp-name-{otp.id} otp-{otp.id}",
expand=True,
)
@property
def css_class(self) -> str:
for c in self.classes:
if re.match("otp-[0-9]", c):
return c
raise RuntimeError("Class not found")
@property
def related(self, arg: str = "") -> DOMQuery[Widget]:
return self.screen.query(f".{self.css_class}{arg}")
def related_remove_class(self, cls: str) -> None:
for widget in self.related:
widget.remove_class(cls)
def related_add_class(self, cls: str) -> None:
for widget in self.related:
widget.add_class(cls)
def on_blur(self) -> None:
self.related_remove_class("otp-focused")
self.otp.value_widget.update("*" * self.otp.totp.digits)
self.shown = False
class TOTPButton(Button, can_focus=False):
def __init__(self, otp: "TOTPData", label: str, classes: str):
self.otp = otp
super().__init__(label=label, classes=classes)
@dataclass
class TOTPData:
totp: pyotp.TOTP
generation = None
name_widget: Label = field(init=False)
value_widget: Label = field(init=False)
progress_widget: ProgressBar = field(init=False)
copy_widget: TOTPButton = field(init=False)
show_widget: TOTPButton = field(init=False)
@property
def id(self) -> int:
return id(self)
def __post_init__(self) -> None:
self.name_widget = TOTPLabel(self)
self.copy_widget = TOTPButton(
self, "🗐 ", classes=f"otp-copy otp-copy-{self.id} otp-{self.id}"
)
self.show_widget = TOTPButton(
self, "👀", classes=f"otp-show otp-show-{self.id} otp-{self.id}"
)
self.value_widget = Label(
"*" * self.totp.digits,
classes=f"otp-value otp-value-{self.id} otp-{self.id}",
expand=True,
)
self.progress_widget = ProgressBar(
classes=f"otp-progress otp-progress-{self.id} otp-{self.id}",
show_percentage=False,
show_eta=False,
)
self.progress_widget.total = self.totp.interval
def tick(self, now: float) -> None:
generation, progress = divmod(now, self.totp.interval)
if generation != self.generation:
self.generation = generation
self.value_widget.update("*" * self.totp.digits)
self.progress_widget.progress = self.totp.interval - progress
@property
def widgets(self) -> Sequence[Widget]:
return (
self.value_widget,
self.name_widget,
self.progress_widget,
self.show_widget,
self.copy_widget,
)
class TTOTP(App[None]):
CSS = """
VerticalScroll { min-height: 1; }
.otp-progress { width: 12; }
.otp-value { width: 9; }
TOTPLabel { width: 1fr; }
Horizontal:focus-within { background: $primary-background; }
Bar > .bar--bar { color: $success; }
Bar { width: 1fr; }
Button { border: none; height: 1; width: 3; min-width: 4 }
Horizontal { height: 1; }
"""
def __init__(self, tokens: Sequence[pyotp.TOTP]) -> None:
super().__init__()
self.tokens = tokens
self.otp_data: list[TOTPData] = []
self.timer: Timer | None = None
self.clear_clipboard_time: Timer | None = None
self.copied = ""
def on_mount(self) -> None:
self.timer_func()
self.timer = self.set_interval(1, self.timer_func)
self.clear_clipboard_timer = self.set_timer(
30, self.clear_clipboard_func, pause=True
)
def clear_clipboard_func(self) -> None:
if pyperclip_paste() == self.copied:
self.notify("Clipboard cleared", title="")
pyperclip_copy("")
def timer_func(self) -> None:
now = time.time()
for otp in self.otp_data:
otp.tick(now)
def compose(self) -> ComposeResult:
yield Footer()
with VerticalScroll() as v:
v.can_focus = False
for otp in self.tokens:
data = TOTPData(otp)
self.otp_data.append(data)
with Horizontal():
yield from data.widgets
def action_show(self) -> None:
widget = self.focused
if widget is not None:
otp = cast(TOTPLabel, widget).otp
otp.value_widget.update(otp.totp.now())
def action_copy(self) -> None:
widget = self.focused
if widget is not None:
otp = cast(TOTPLabel, widget).otp
code = otp.totp.now()
pyperclip_copy(code)
self.copied = code
self.clear_clipboard_timer.reset()
self.clear_clipboard_timer.resume()
self.notify("Code copied", title="")
def on_button_pressed(self, event: Button.Pressed) -> None:
button = cast(TOTPButton, event.button)
self.screen.set_focus(button.otp.name_widget)
if "otp-show" in button.classes:
self.action_show()
else:
self.action_copy()
@click.command
@click.option(
"--config",
type=pathlib.Path,
default=default_conffile,
help="Configuration file to use",
)
@click.option(
"--profile",
type=str,
default=None,
help="Profile to use within the configuration file",
)
def main(config: pathlib.Path, profile: str) -> None:
def config_hint(extra: str) -> None:
config.parent.mkdir(parents=True, exist_ok=True)
print(
f"""\
You need to create the configuration file:
{config}
It's a toml file which specifies a command to run to retrieve the list of OTPs.
One way to do this is with the `pass` program (https://www.passwordstore.org/)
`pass` keeps your secrets safe using GPG. Typical contents:
otp-command = ['pass', 'totp-tokens']
By default, the otp-command in the global section is used. You can have
multiple profiles as configuration file sections, and select one with
`ttotp --profile profile-name`:
[work]
otp-command = ['pass', 'totp-tokens-work']
{extra}"""
)
raise SystemExit(2)
if not config.exists():
config_hint(f"The configuration file {config} does not exist.")
with open(config, "rb") as f:
config_data = tomllib.load(f)
if profile:
config_data = config_data.get(profile, None)
if config_data is None:
config_hint(f"The profile {profile!r} file does not exist.")
otp_command = config_data.get("otp-command")
if otp_command is None:
config_hint("The otp-command value is missing.")
if isinstance(otp_command, str) or is_str_list(otp_command):
c = subprocess.check_output(
otp_command, shell=isinstance(otp_command, str), text=True
)
else:
config_hint("The otp-command value must be a string or list of strings.")
tokens: list[pyotp.TOTP] = []
for row in c.strip().split("\n"):
if row.startswith("otpauth://"):
tokens.append(parse_uri(row))
if not tokens:
config_hint("No tokens were found when running the given command.")
TTOTP(tokens).run()
if __name__ == "__main__":
main()

260
ttotp.py
View file

@ -1,260 +0,0 @@
#!/usr/bin/python3
from dataclasses import dataclass
import time
import hashlib
import pathlib
import subprocess
from urllib.parse import parse_qsl, unquote, urlparse
import re
from textual.app import App
from textual.widgets import Label, Footer, ProgressBar
from textual.binding import Binding
from textual.containers import VerticalScroll
import pyperclip
import click
import pyotp
import platformdirs
import tomllib
# Copied from pyotp with the issuer mismatch check removed
def parse_uri(uri: str) -> pyotp.OTP:
"""
Parses the provisioning URI for the OTP; works for either TOTP or HOTP.
See also:
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
:param uri: the hotp/totp URI to parse
:returns: OTP object
"""
# Secret (to be filled in later)
secret = None
# Data we'll parse to the correct constructor
otp_data = {} # type: Dict[str, Any]
# Parse with URLlib
parsed_uri = urlparse(unquote(uri))
if parsed_uri.scheme != "otpauth":
raise ValueError("Not an otpauth URI")
# Parse issuer/accountname info
accountinfo_parts = re.split(":|%3A", parsed_uri.path[1:], maxsplit=1)
if len(accountinfo_parts) == 1:
otp_data["name"] = accountinfo_parts[0]
else:
otp_data["issuer"] = accountinfo_parts[0]
otp_data["name"] = accountinfo_parts[1]
# Parse values
for key, value in parse_qsl(parsed_uri.query):
if key == "secret":
secret = value
elif key == "issuer":
otp_data["issuer"] = value
elif key == "algorithm":
if value == "SHA1":
otp_data["digest"] = hashlib.sha1
elif value == "SHA256":
otp_data["digest"] = hashlib.sha256
elif value == "SHA512":
otp_data["digest"] = hashlib.sha512
else:
raise ValueError("Invalid value for algorithm, must be SHA1, SHA256 or SHA512")
elif key == "digits":
digits = int(value)
if digits not in [6, 7, 8]:
raise ValueError("Digits may only be 6, 7, or 8")
otp_data["digits"] = digits
elif key == "period":
otp_data["interval"] = int(value)
elif key == "counter":
otp_data["initial_count"] = int(value)
elif key != "image":
raise ValueError("{} is not a valid parameter".format(key))
if not secret:
raise ValueError("No secret found in URI")
# Create objects
if parsed_uri.netloc == "totp":
return pyotp.TOTP(secret, **otp_data)
elif parsed_uri.netloc == "hotp":
return pyotp.HOTP(secret, **otp_data)
raise ValueError("Not a supported OTP type")
default_conffile = platformdirs.user_config_path("ttotp") / "settings.toml"
class TOTPLabel(Label, can_focus=True):
BINDINGS = [
Binding("c", "copy", "Copy code", show=True),
Binding("s", "show", "Show code", show=True),
]
@property
def idx(self):
return int(self.css_class.split('-')[1])
@property
def css_class(self):
for c in self.classes:
if re.match('otp-[0-9]', c):
return c
return None
@property
def related(self, arg=''):
return self.screen.query(f'.{self.css_class}{arg}')
def related_remove_class(self, cls):
for widget in self.related:
widget.remove_class(cls)
def related_add_class(self, cls):
for widget in self.related:
widget.add_class(cls)
def on_blur(self):
self.related_remove_class("otp-focused")
self.shown = False
def on_focus(self):
self.related_add_class("otp-focused")
@dataclass
class TOTPData:
totp: pyotp.TOTP
name_widget: Label
value_widget: Label
progress_widget: ProgressBar
generation = None
def tick(self, now):
now = time.time()
generation, progress = divmod(now, self.totp.interval)
if generation != self.generation:
self.generation = generation
self.value_widget.update("*" * self.totp.digits)
self.progress_widget.progress = self.totp.interval - progress
class TTOTP(App[None]):
CSS = '''
VerticalScroll {
layout: grid;
grid-size: 2;
grid-columns: 8 1fr;
grid-rows: 1;
}
.otp-focused { background: $primary-background; }
ProgressBar { column-span: 2; }
Bar > .bar--bar { color: $success; }
Bar { width: 1fr; }
'''
def __init__(self, tokens):
super().__init__()
self.tokens = tokens
self.otp_data = {}
self.timer = None
self.clear_clipboard_timer = None
self.copied = None
def on_mount(self):
self.timer_func()
self.timer = self.set_interval(.1, self.timer_func)
self.clear_clipboard_timer = self.set_timer(30, self.clear_clipboard_func, pause=True)
def clear_clipboard_func(self):
if pyperclip.paste() == self.copied:
self.notify("Clipboard cleared", title="")
pyperclip.copy('')
def timer_func(self):
now = time.time()
for otp in self.otp_data.values():
otp.tick(now)
def compose(self):
yield Footer()
with VerticalScroll() as v:
v.can_focus = False
for i, otp in enumerate(self.tokens):
otp_name = TOTPLabel(f"{otp.name} / {otp.issuer}", classes=f"otp-name otp-name-{i} otp-{i}", expand=True)
otp_value = Label("", classes=f"otp-value otp-value-{i} otp-{i}", expand=True)
otp_progress = ProgressBar(classes=f"otp-progress otp-progress-{i} otp-{i}", show_percentage=False, show_eta=False)
otp_progress.total = otp.interval
otpdata = TOTPData(otp, otp_name, otp_value, otp_progress)
self.otp_data[otp] = self.otp_data[i] = otpdata
yield otp_value
yield otp_name
yield otp_progress
def action_show(self):
widget = self.focused
otp = self.otp_data[widget.idx]
otp.value_widget.update(otp.totp.now())
def action_copy(self):
widget = self.focused
otp = self.otp_data[widget.idx]
code = otp.totp.now()
pyperclip.copy(code)
self.copied = code
self.clear_clipboard_timer.reset()
self.clear_clipboard_timer.resume()
self.notify("Code copied", title="")
@click.command
@click.option("--config", type=pathlib.Path, default=default_conffile, help="Configuration file to use")
def main(config):
def config_hint():
config.parent.mkdir(parents=True, exist_ok=True)
print(f"""\
You need to create the configuration file: {config}
It's a toml file which specifies a command to run to retrieve the list of OTPs.
One way to do this is with the `pass` program from https://www.passwordstore.org/
(it keeps your secrets safe using GPG):
otp-command = ['pass', 'totp-tokens']
""")
raise SystemExit(2)
if not config.exists():
config_hint()
with open(config, "rb") as f:
config_data = tomllib.load(f)
print(config_data)
otp_command = config_data.get('otp-command')
if otp_command is None:
config_hint()
c = subprocess.check_output(otp_command, shell=isinstance(otp_command, str), text=True)
print(f"{c=!r}")
global tokens
tokens = []
for row in c.strip().split('\n'):
if row.startswith("otpauth://"):
print(f"parsing {row=!r}")
tokens.append(parse_uri(row))
TTOTP(tokens).run()
if __name__ == '__main__':
main()