new version
This commit is contained in:
491
v2.0/mfa_tray.py
Normal file
491
v2.0/mfa_tray.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user