From 8c57067346388097901fe0a4ce1a16824c33f5e4 Mon Sep 17 00:00:00 2001 From: Vincent Hanewinkel Date: Mon, 30 Mar 2026 15:05:10 +0200 Subject: [PATCH] new version --- .gitignore => v1.0/.gitignore | 0 OtpViewer.py => v1.0/OtpViewer.py | 0 README.md => v1.0/README.md | 0 otp-icon.ico => v1.0/otp-icon.ico | Bin otp-icon.png => v1.0/otp-icon.png | Bin png2icon.py => v1.0/png2icon.py | 0 requirments.txt => v1.0/requirments.txt | 0 v2.0/mfa_tray.py | 491 ++++++++++++++++++++++++ 8 files changed, 491 insertions(+) rename .gitignore => v1.0/.gitignore (100%) rename OtpViewer.py => v1.0/OtpViewer.py (100%) rename README.md => v1.0/README.md (100%) rename otp-icon.ico => v1.0/otp-icon.ico (100%) rename otp-icon.png => v1.0/otp-icon.png (100%) rename png2icon.py => v1.0/png2icon.py (100%) rename requirments.txt => v1.0/requirments.txt (100%) create mode 100644 v2.0/mfa_tray.py diff --git a/.gitignore b/v1.0/.gitignore similarity index 100% rename from .gitignore rename to v1.0/.gitignore diff --git a/OtpViewer.py b/v1.0/OtpViewer.py similarity index 100% rename from OtpViewer.py rename to v1.0/OtpViewer.py diff --git a/README.md b/v1.0/README.md similarity index 100% rename from README.md rename to v1.0/README.md diff --git a/otp-icon.ico b/v1.0/otp-icon.ico similarity index 100% rename from otp-icon.ico rename to v1.0/otp-icon.ico diff --git a/otp-icon.png b/v1.0/otp-icon.png similarity index 100% rename from otp-icon.png rename to v1.0/otp-icon.png diff --git a/png2icon.py b/v1.0/png2icon.py similarity index 100% rename from png2icon.py rename to v1.0/png2icon.py diff --git a/requirments.txt b/v1.0/requirments.txt similarity index 100% rename from requirments.txt rename to v1.0/requirments.txt diff --git a/v2.0/mfa_tray.py b/v2.0/mfa_tray.py new file mode 100644 index 0000000..c4631f7 --- /dev/null +++ b/v2.0/mfa_tray.py @@ -0,0 +1,491 @@ +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() \ No newline at end of file