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()