Files
Modus/mine/modules/launcher/plugins/otp.py
T
2026-07-12 18:56:10 +05:45

447 lines
14 KiB
Python

import json
from collections.abc import Iterator
from pathlib import Path
from fabric.utils import Gdk, GLib, idle_add, remove_handler
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from config.data import plugins
from modules.launcher.base import LauncherPlugin
from services import (
generate_totp,
get_time_remaining,
scan_qr_and_add_account,
validate_base32_secret,
)
from utils.functions import copy_text, trigger_paste_shortcut
class OTPPlugin(LauncherPlugin):
@property
def name(self) -> str:
return "otp"
@property
def icon(self) -> str:
return "security-high-symbolic"
@property
def keywords(self) -> list[str]:
return ["otp"]
def __init__(self, handler):
super().__init__(handler)
self.secrets_file_path = Path(plugins) / "accounts.json"
self.secrets_file_path.parent.mkdir(parents=True, exist_ok=True)
self._query_handler: int = 0
self._timer_handler: int = 0
self.accounts: dict = {}
self.current_slots: list = []
self._load_accounts()
def on_search(self, text: str) -> None:
self.query_otp(text)
def stop(self):
if self._query_handler:
remove_handler(self._query_handler)
self._query_handler = 0
if self._timer_handler:
GLib.source_remove(self._timer_handler)
self._timer_handler = 0
self.current_slots.clear()
def _load_accounts(self) -> None:
self.accounts.clear()
if not self.secrets_file_path.exists():
return
try:
with open(self.secrets_file_path, encoding="utf-8") as f:
self.accounts = json.load(f)
except (OSError, json.JSONDecodeError):
self.accounts = {}
def _save_accounts(self) -> None:
try:
with open(self.secrets_file_path, "w", encoding="utf-8") as f:
json.dump(self.accounts, f, indent=2)
except OSError:
pass
def query_otp(self, text: str) -> None:
self.handler.start("otp")
self._load_accounts()
self.current_slots.clear()
if self._timer_handler:
GLib.source_remove(self._timer_handler)
self._timer_handler = 0
immediate_commands = {
"add": self._handle_add_command,
"remove": self._handle_remove_command,
"help": lambda t: self._show_command_help(),
}
for prefix, handler in immediate_commands.items():
if text.strip().startswith(prefix):
handler(text)
return
if not self.accounts:
self._show_no_accounts()
return
filtered_accounts = self._filter_accounts(text)
if not filtered_accounts:
self._show_no_results()
return
self._query_handler = idle_add(
self._bake_next_otp_slot, iter(filtered_accounts), pin=True
)
def _filter_accounts(self, text: str) -> Iterator[tuple[str, dict]]:
if not text.strip():
return iter(self.accounts.items())
text_lower = text.lower()
return iter(
(name, data)
for name, data in self.accounts.items()
if text_lower in name.lower()
)
def _show_no_accounts(self) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot otp-no-accounts",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label="No OTP accounts found",
style_classes="otp-no-accounts-text",
h_align="center",
),
],
),
tooltip_text="Use 'add account_name qr' or 'add account_name secret_key' to add accounts",
),
"otp",
)
self.handler.done()
def _show_no_results(self) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot otp-no-results",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label="No accounts match your search",
style_classes="otp-no-results-text",
h_align="center",
),
],
),
),
"otp",
)
self.handler.done()
def _bake_next_otp_slot(self, iterator: Iterator[tuple[str, dict]]) -> bool:
try:
account_name, account_data = next(iterator)
except StopIteration:
if self.current_slots:
self._start_timer()
idle_add(self.handler.done)
return False
self._create_otp_slot(account_name, account_data)
return True
def _create_otp_slot(self, account_name: str, account_data: dict) -> None:
secret = account_data.get("secret", "")
otp_code = generate_totp(secret)
if not otp_code:
return
display_name = account_name
time_remaining = get_time_remaining()
timer_label = Label(
label=f"{time_remaining}s",
style_classes="otp-timer",
h_align="end",
)
code_label = Label(
label=otp_code,
style_classes="otp-code",
h_align="start",
)
name_label = Label(
label=display_name,
style_classes="otp-account-name",
h_align="start",
max_chars_width=35,
ellipsization="end",
)
tooltip_text = "Click to copy OTP code"
slot_widget = Button(
style_classes="app-slot otp-slot",
child=Box(
orientation="v",
spacing=4,
children=[
name_label,
Box(
orientation="h",
spacing=8,
children=[
code_label,
timer_label,
],
),
],
),
tooltip_text=tooltip_text,
on_clicked=lambda *_: self._copy_otp_code(otp_code),
)
slot_widget.connect(
"key-press-event",
lambda widget, event, acc_name=account_name: self._on_slot_key_press(
widget, event, acc_name
),
)
slot_info = {
"widget": slot_widget,
"timer_label": timer_label,
"code_label": code_label,
"account_name": account_name,
"account_data": account_data,
}
self.current_slots.append(slot_info)
self.handler.slot_ready(slot_widget, "otp")
def _copy_otp_code(self, code: str) -> None:
copy_text(code)
self.handler.launched()
GLib.timeout_add(5, lambda: (trigger_paste_shortcut(0), False)[1])
def _on_slot_key_press(self, widget, event, account_name: str) -> bool:
if not event:
return False
return (
(event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter)
and event.state & Gdk.ModifierType.SHIFT_MASK
and (
self.remove_account(account_name)
and self._refresh_account_list()
or True
)
)
def add_account_manually(self, account_name: str, secret: str) -> bool:
validation = validate_base32_secret(secret)
if not validation["success"]:
return False
self.accounts[account_name] = {
"secret": validation["secret"],
"algorithm": "SHA1",
"digits": 6,
"period": 30,
}
self._save_accounts()
return True
def remove_account(self, account_name: str) -> bool:
if account_name not in self.accounts:
return False
del self.accounts[account_name]
self._save_accounts()
return True
def _start_timer(self) -> None:
if self._timer_handler:
GLib.source_remove(self._timer_handler)
self._timer_handler = GLib.timeout_add_seconds(1, self._update_timers)
def _update_timers(self) -> bool:
if not self.current_slots:
return False
for slot_info in self.current_slots:
secret = slot_info["account_data"].get("secret", "")
otp_code = generate_totp(secret)
if otp_code:
slot_info["code_label"].set_text(otp_code)
slot_info["timer_label"].set_text(f"{get_time_remaining()}s")
return True
def _refresh_account_list(self) -> None:
self.current_slots.clear()
if self._timer_handler:
GLib.source_remove(self._timer_handler)
self._timer_handler = 0
if hasattr(self.handler, "clear_viewport"):
self.handler.clear_viewport()
self._load_accounts()
idle_add(self._show_account_list)
def _show_account_list(self) -> None:
self.handler.start("otp")
if not self.accounts:
self._show_no_accounts()
return
filtered_accounts = self._filter_accounts("")
if not filtered_accounts:
self._show_no_results()
return
self._query_handler = idle_add(
self._bake_next_otp_slot, iter(filtered_accounts), pin=True
)
def _handle_add_command(self, text: str) -> None:
text_stripped = text.strip()
parts = text_stripped.split()
if len(parts) < 3:
self._show_command_help("add")
return
account_name = parts[1]
secret_or_qr = parts[2].rstrip(";") # Remove semicolon if present
if secret_or_qr.lower() == "qr":
# QR code scanning doesn't require semicolon
result = scan_qr_and_add_account(account_name, str(self.secrets_file_path))
if not result["success"]:
self._show_error_message(f"Error adding account: {result['error']}")
return
else:
# Secret key must end with semicolon
if not text_stripped.endswith(";"):
self._show_error_message(
"Add command with secret key must end with ';'\n\nUsage: add account_name secret_key;"
)
return
# Remove semicolon and extract secret key (handles spaces in secret)
command_text = text_stripped[:-1] # Remove semicolon
# Split into max 3 parts: ["add", "account_name", "secret_key with spaces"]
command_parts = command_text.split(None, 2)
if len(command_parts) < 3:
self._show_command_help("add")
return
account_name = command_parts[1]
secret_key = command_parts[2] # This will contain the entire secret key
if not self.add_account_manually(account_name, secret_key):
self._show_error_message(f"Failed to add account: {account_name}")
return
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self._refresh_account_list()
def _handle_remove_command(self, text: str) -> None:
if text.strip() == "remove":
self._show_account_list()
return
parts = text.strip().split()
if len(parts) < 2:
self._show_command_help("remove")
return
account_name = parts[1]
if not self.remove_account(account_name):
self._show_error_message(f"Account not found: {account_name}")
return
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self._refresh_account_list()
def _show_command_help(self, command: str = None) -> None:
help_texts = {
"add": "Usage: add account_name qr\nadd account_name secret_key;",
"remove": "Usage: remove account_name",
}
help_text = help_texts.get(
command,
"""Available commands:
• add account_name qr
• add account_name secret_key;
• remove account_name
• help
Click on any OTP code to copy it!""",
)
self.handler.slot_ready(
Button(
style_classes="app-slot otp-help",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label=help_text,
style_classes="otp-help-text",
h_align="center",
),
],
),
),
"otp",
)
self.handler.done()
def _show_error_message(self, message: str) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot otp-error",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label=message,
style_classes="otp-error-text",
h_align="center",
),
],
),
on_clicked=lambda *_: self.handler.launched(),
),
"otp",
)
self.handler.done()
def handle_external(self, command: str, args: str) -> None:
# Map external commands if needed
pass