Files
OTP-Viewer/v2.0/mfa_tray.py
Vincent Hanewinkel 8c57067346 new version
2026-03-30 15:05:10 +02:00

491 lines
16 KiB
Python

import json
import os
import sys
import threading
import queue
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import pyotp
import pystray
from PIL import Image, ImageDraw
import tkinter as tk
from tkinter import messagebox
# -----------------------------
# Config
# -----------------------------
def get_config_path() -> Path:
# Windows: %APPDATA%\MfaTray\config.json
# Linux/macOS: ~/.config/MfaTray/config.json
if os.name == "nt":
base = Path(os.environ.get("APPDATA", str(Path.home())))
else:
base = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
cfg_dir = base / "MfaTray"
cfg_dir.mkdir(parents=True, exist_ok=True)
return cfg_dir / "config.json"
@dataclass
class AppConfig:
account_name: str = ""
otp_seed_base32: str = ""
@staticmethod
def load(path: Path) -> "AppConfig":
if not path.exists():
return AppConfig()
try:
data = json.loads(path.read_text(encoding="utf-8"))
return AppConfig(
account_name=str(data.get("account_name", "")).strip(),
otp_seed_base32=str(data.get("otp_seed_base32", "")).strip().replace(" ", ""),
)
except Exception:
# kaputte Config -> leer starten
return AppConfig()
def save(self, path: Path) -> None:
payload = {
"account_name": self.account_name.strip(),
"otp_seed_base32": self.otp_seed_base32.strip().replace(" ", ""),
}
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
# -----------------------------
# TOTP helpers
# -----------------------------
def safe_totp_code(seed_base32: str) -> Optional[str]:
seed = (seed_base32 or "").strip().replace(" ", "")
if not seed:
return None
try:
totp = pyotp.TOTP(seed)
return totp.now()
except Exception:
return None
def seconds_remaining(period: int = 30) -> int:
return period - (int(time.time()) % period)
# -----------------------------
# UI Windows (Tkinter)
# -----------------------------
class TokenWindow:
def __init__(self, master: tk.Tk, on_close):
self.on_close = on_close
self.win = tk.Toplevel(master)
self.win.title("MFA Token")
self.win.resizable(False, False)
self.lbl_account = tk.Label(self.win, text="Account: -", font=("Segoe UI", 11))
self.lbl_account.pack(padx=12, pady=(12, 4), anchor="w")
self.lbl_code = tk.Label(self.win, text="------", font=("Consolas", 26, "bold"))
self.lbl_code.pack(padx=12, pady=(4, 4))
self.lbl_timer = tk.Label(self.win, text="Gültig für: --s", font=("Segoe UI", 10))
self.lbl_timer.pack(padx=12, pady=(0, 10))
btn_frame = tk.Frame(self.win)
btn_frame.pack(padx=12, pady=(0, 12), fill="x")
self.btn_copy = tk.Button(btn_frame, text="Kopieren", command=self.copy_code)
self.btn_copy.pack(side="left")
self.btn_close = tk.Button(btn_frame, text="Schließen", command=self.close)
self.btn_close.pack(side="right")
self._current_code = ""
self.win.protocol("WM_DELETE_WINDOW", self.close)
def set_data(self, account: str, code: str, remaining: int):
self._current_code = code or ""
self.lbl_account.config(text=f"Account: {account or '-'}")
self.lbl_code.config(text=code or "------")
self.lbl_timer.config(text=f"Gültig für: {remaining:02d}s")
def copy_code(self):
if not self._current_code:
return
self.win.clipboard_clear()
self.win.clipboard_append(self._current_code)
self.win.update()
def show(self):
self.win.deiconify()
self.win.lift()
self.win.attributes("-topmost", True)
self.win.after(50, lambda: self.win.attributes("-topmost", False))
def close(self):
try:
self.win.destroy()
finally:
self.on_close()
class SettingsWindow:
def __init__(self, master: tk.Tk, initial: AppConfig, on_save, on_close):
self.on_save = on_save
self.on_close = on_close
self.win = tk.Toplevel(master)
self.win.title("Einstellungen")
self.win.resizable(False, False)
frm = tk.Frame(self.win)
frm.pack(padx=12, pady=12)
tk.Label(frm, text="Accountname:", font=("Segoe UI", 10)).grid(row=0, column=0, sticky="w")
self.var_account = tk.StringVar(value=initial.account_name)
tk.Entry(frm, textvariable=self.var_account, width=38).grid(row=1, column=0, sticky="we", pady=(2, 10))
tk.Label(frm, text="OTP Seed (Base32):", font=("Segoe UI", 10)).grid(row=2, column=0, sticky="w")
self.var_seed = tk.StringVar(value=initial.otp_seed_base32)
self.ent_seed = tk.Entry(frm, textvariable=self.var_seed, width=38, show="")
self.ent_seed.grid(row=3, column=0, sticky="we", pady=(2, 6))
self.var_show = tk.BooleanVar(value=False)
tk.Checkbutton(frm, text="Seed anzeigen", variable=self.var_show, command=self.toggle_seed).grid(
row=4, column=0, sticky="w", pady=(0, 10)
)
btns = tk.Frame(frm)
btns.grid(row=5, column=0, sticky="we")
tk.Button(btns, text="Speichern", command=self.save).pack(side="left")
tk.Button(btns, text="Abbrechen", command=self.close).pack(side="right")
self.win.protocol("WM_DELETE_WINDOW", self.close)
def toggle_seed(self):
self.ent_seed.config(show="" if self.var_show.get() else "")
def save(self):
account = self.var_account.get().strip()
seed = self.var_seed.get().strip().replace(" ", "")
if seed and safe_totp_code(seed) is None:
messagebox.showerror(
"Ungültiger Seed",
"Der OTP Seed scheint ungültig zu sein.\nAchte darauf, dass es ein Base32-Seed ist.",
parent=self.win
)
return
self.on_save(AppConfig(account_name=account, otp_seed_base32=seed))
self.close()
def show(self):
self.win.deiconify()
self.win.lift()
self.win.attributes("-topmost", True)
self.win.after(50, lambda: self.win.attributes("-topmost", False))
def close(self):
try:
self.win.destroy()
finally:
self.on_close()
# -----------------------------
# Tray Icon
# -----------------------------
def create_icon_image(size: int = 64) -> Image.Image:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
# Simple shield-like icon
pad = int(size * 0.12)
d.rounded_rectangle([pad, pad, size - pad, size - pad], radius=int(size * 0.18), fill=(30, 144, 255, 255))
d.text((size * 0.34, size * 0.23), "MFA", fill=(255, 255, 255, 255))
d.rounded_rectangle(
[int(size * 0.22), int(size * 0.52), int(size * 0.78), int(size * 0.72)],
radius=int(size * 0.10),
fill=(255, 255, 255, 255),
)
return img
class MfaTrayApp:
def __init__(self):
self.config_path = get_config_path()
self.config = AppConfig.load(self.config_path)
self.ui_queue: "queue.Queue[tuple]" = queue.Queue()
self.root: Optional[tk.Tk] = None # wird im main gesetzt
self._lock = threading.Lock()
self._stop = threading.Event()
self._token_window_thread: Optional[threading.Thread] = None
self._settings_window_thread: Optional[threading.Thread] = None
self._token_window: Optional[TokenWindow] = None
self._settings_window: Optional[SettingsWindow] = None
self.icon = pystray.Icon(
"MfaTray",
create_icon_image(),
"MFA Tray",
menu=pystray.Menu(
# Default = wird bei Linksklick ausgelöst (Windows/pystray)
pystray.MenuItem("Code kopieren", self.on_copy_code, default=True),
pystray.MenuItem("Token anzeigen", self.on_show_token),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Info", self.on_info),
pystray.MenuItem("Einstellungen", self.on_settings),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Beenden", self.on_quit),
),
)
# Left click handler: show token window
# pystray supports an optional "on_clicked" callback
self.icon.on_clicked = self.on_clicked
# Background updater
self._updater_thread = threading.Thread(target=self._update_loop, daemon=True)
def run(self):
self._updater_thread.start()
# Tray in Background-Thread starten
threading.Thread(target=self.icon.run, daemon=True).start()
def on_clicked(self, icon, item):
if item is None:
self.on_copy_code(icon, item)
def on_info(self, icon, item):
msg = (
"MFA Tray\n\n"
"• Linksklick: Token anzeigen\n"
"• Rechtsklick: Menü\n\n"
f"Config: {self.config_path}"
)
self._show_message_box("Info", msg)
def on_show_token(self, icon, item):
self.show_token_window()
def on_copy_code(self, icon, item):
with self._lock:
account = self.config.account_name
seed = self.config.otp_seed_base32
if not seed:
self._show_message_box("MFA Tray", "Kein Seed konfiguriert.\nBitte in Einstellungen hinterlegen.")
return
code = safe_totp_code(seed)
if code is None:
self._show_message_box("MFA Tray", "Seed ungültig.\nBitte prüfen (Base32).")
return
# Clipboard & Hinweis IM Tk-Thread
self.ui_queue.put(("copy", code, account))
# 3) Kurzer Hinweis
self._show_message_box("MFA Tray", f"Code kopiert:\n{account or '-'} {code}")
def on_settings(self, icon, item):
self.show_settings_window()
def on_quit(self, icon, item):
self._stop.set()
try:
self.icon.stop()
except Exception:
pass
def _show_message_box(self, title: str, msg: str):
# IMMER nur per UI-Queue ausführen (läuft dann im Tk-Thread)
self.ui_queue.put(("msgbox", title, msg))
def show_token_window(self):
self.ui_queue.put(("show_token",))
def _worker():
def _on_close():
with self._lock:
self._token_window = None
win = TokenWindow(on_close=_on_close)
with self._lock:
self._token_window = win
# initial fill
account, code, rem = self._compute_display_values()
win.set_data(account, code, rem)
win.show()
win.mainloop()
self._token_window_thread = threading.Thread(target=_worker, daemon=True)
self._token_window_thread.start()
def show_settings_window(self):
self.ui_queue.put(("show_settings",))
def _worker():
def _on_close():
with self._lock:
self._settings_window = None
def _on_save(new_cfg: AppConfig):
with self._lock:
self.config = new_cfg
self.config.save(self.config_path)
win = SettingsWindow(initial=self.config, on_save=_on_save, on_close=_on_close)
with self._lock:
self._settings_window = win
win.show()
win.mainloop()
self._settings_window_thread = threading.Thread(target=_worker, daemon=True)
self._settings_window_thread.start()
def _compute_display_values(self):
with self._lock:
account = self.config.account_name
seed = self.config.otp_seed_base32
code = safe_totp_code(seed) if seed else None
rem = seconds_remaining(30)
if not seed:
return (account or "-", "nicht konfiguriert", rem)
if code is None:
return (account or "-", "Seed ungültig", rem)
return (account or "-", code, rem)
def _update_loop(self):
# Update tooltip + token window content every second
last_tooltip = None
while not self._stop.is_set():
account, code, rem = self._compute_display_values()
tooltip = f"{account}: {code} ({rem:02d}s)"
if tooltip != last_tooltip:
try:
self.icon.title = tooltip # tooltip text
except Exception:
pass
last_tooltip = tooltip
# update open token window
with self._lock:
win_open = self._token_window is not None
if win_open:
# Update im Tk-Thread
shown_code = "" if code in ("Seed ungültig", "nicht konfiguriert") else code
self.ui_queue.put(("token_update", account, shown_code, rem))
time.sleep(1)
def ui_loop_init(self, root: tk.Tk):
self.root = root
def pump():
# regelmäßig Queue abarbeiten
while True:
try:
evt = self.ui_queue.get_nowait()
except queue.Empty:
break
kind = evt[0]
if kind == "msgbox":
_, title, msg = evt
messagebox.showinfo(title, msg, parent=self.root)
elif kind == "copy":
_, code, account = evt
self.root.clipboard_clear()
self.root.clipboard_append(code)
self.root.update()
messagebox.showinfo("MFA Tray", f"Code kopiert:\n{account or '-'} {code}", parent=self.root)
elif kind == "show_token":
with self._lock:
if self._token_window is not None:
self._token_window.show()
continue
def _on_close():
with self._lock:
self._token_window = None
win = TokenWindow(self.root, on_close=_on_close)
with self._lock:
self._token_window = win
account, code, rem = self._compute_display_values()
# Wenn nicht konfiguriert/ungültig: im Fenster sinnvoll anzeigen
if code in ("Seed ungültig", "nicht konfiguriert"):
win.set_data(account, "------", rem)
else:
win.set_data(account, code, rem)
win.show()
elif kind == "show_settings":
with self._lock:
if self._settings_window is not None:
self._settings_window.show()
continue
def _on_close():
with self._lock:
self._settings_window = None
def _on_save(new_cfg: AppConfig):
with self._lock:
self.config = new_cfg
self.config.save(self.config_path)
win = SettingsWindow(self.root, initial=self.config, on_save=_on_save, on_close=_on_close)
with self._lock:
self._settings_window = win
win.show()
elif kind == "token_update":
_, account, shown_code, rem = evt
with self._lock:
win = self._token_window
if win is not None:
win.set_data(account, shown_code or "------", rem)
self.root.after(100, pump)
pump()
if __name__ == "__main__":
app = MfaTrayApp()
app.run()
# Tk MUSS im Main Thread laufen
root = tk.Tk()
root.withdraw()
app.ui_loop_init(root)
root.mainloop()