fix(tray): architecture hybride pystray (thread) + Tk.mainloop() (thread principal) — les callbacks dispatch via root.after() pour que Tk ait sa boucle d'événements ; fenêtres peintes et interactives

This commit is contained in:
Hermes Agent
2026-09-18 10:54:03 +02:00
parent e7d13d9ad1
commit 2f5c428545
2 changed files with 50 additions and 43 deletions
+5 -3
View File
@@ -71,12 +71,14 @@ def run_tray(store: ConfigStore) -> int:
from .tray import TrayApp
app = TrayApp(store)
root = app.get_root() # force la création de la racine Tk (cachée)
app.start() # lance pystray dans un thread d'arrière-plan
LOG.info("Entrée dans la boucle Tk principale.")
try:
# start() bloque (Icon.run() sur le thread principal) et retourne
# quand l'utilisateur clique sur « Quitter ».
app.start()
root.mainloop() # bloque sur le thread principal ; traite les événements Tk
except KeyboardInterrupt:
app.stop()
LOG.info("Boucle Tk terminée.")
return 0
+45 -40
View File
@@ -8,8 +8,11 @@ seule une icône dans la zone de notification. Le menu de l'icône permet :
- Gérer l'authentification (clés d'API sécurisées)
- Quitter
La fenêtre racine Tk est créée de manière invisible et sert uniquement de
référence pour les dialogues ; l'icône est pilotée par pystray.
Architecture hybride pystray + Tkinter :
* pystray tourne dans un thread d'arrière-plan (fonctionne sous Windows).
* Tk.mainloop() bloque le thread principal.
* Les callback de pystray s'exécutent sur le thread d'arrière-plan et
délèguent les opérations Tk au thread principal via root.after().
"""
from __future__ import annotations
@@ -48,7 +51,6 @@ class TrayApp:
self._icon_thread: threading.Thread | None = None
self._icon_factory = icon_factory
self._menu_factory = menu_factory
self._exit_event = threading.Event()
# -- fenêtre racine cachée (pour les dialogues) --------------------------
@@ -59,12 +61,19 @@ class TrayApp:
self._root.withdraw()
return self._root
# -- dispatch vers le thread principal Tk --------------------------------
def _defer_tk(self, fn: Callable) -> None:
"""Exécute `fn` sur le thread principal via root.after(0, …)."""
root = self.get_root()
root.after(0, fn)
# -- actions du menu -------------------------------------------------------
def show_logs(self) -> None:
def _show_logs(self) -> None:
LogsWindow(self.get_root())
def edit_profile(self, name: str | None = None) -> None:
def _edit_profile(self, name: str | None = None) -> None:
"""Ouvre l'éditeur du profil `name`, ou le profil actif si `None`."""
store = self.store
target = store.get(name) if name else store.active()
@@ -77,7 +86,7 @@ class TrayApp:
except Exception as exc:
LOG.exception("Impossible d'enregistrer le profil : %s", exc)
def add_profile(self) -> None:
def _add_profile(self) -> None:
dlg = ProfileDialog(self.get_root(), existing=None, secure=self.secure)
self.get_root().wait_window(dlg)
if dlg.result:
@@ -87,15 +96,16 @@ class TrayApp:
except Exception as exc:
LOG.exception("Impossible d'ajouter le profil : %s", exc)
def set_active(self, name: str) -> None:
def _set_active(self, name: str) -> None:
try:
self.store.set_active(name)
LOG.info("Profil actif : %s", name)
except Exception as exc:
LOG.exception("Impossible de sélectionner le profil : %s", exc)
def manage_auth(self) -> None:
AuthDialog(self.get_root(), self.secure)
def _manage_auth(self) -> None:
dlg = AuthDialog(self.get_root(), self.secure)
self.get_root().wait_window(dlg)
# -- construction de l'icône ----------------------------------------------
@@ -111,10 +121,10 @@ class TrayApp:
menu_items = []
menu_items.append(
self._menu_item("Ouvrir les logs", self._guard("Ouvrir les logs", self.show_logs))
self._menu_item("Ouvrir les logs", self._guard("Ouvrir les logs", self._show_logs))
)
menu_items.append(
self._menu_item("Ajouter un profil", self._guard("Ajouter un profil", self.add_profile))
self._menu_item("Ajouter un profil", self._guard("Ajouter un profil", self._add_profile))
)
# Sous-menu des profils
profiles_sub = pystray.Menu(
@@ -135,7 +145,7 @@ class TrayApp:
menu_items.append(
self._menu_item(
"Gérer l'authentification",
self._guard("Gérer l'authentification", self.manage_auth),
self._guard("Gérer l'authentification", self._manage_auth),
)
)
menu_items.append(pystray.Menu.SEPARATOR)
@@ -160,41 +170,41 @@ class TrayApp:
return pystray.MenuItem(text, action)
def _guard(self, label: str, action: Callable) -> Callable:
"""Enveloppe une action de menu (sans argument requis par l'appelant).
"""Enveloppe une action de menu.
pystray invoque les actions de menu avec ``(icon, item)`` et examine
le nombre de paramètres : un handler à exactement 2 params est appelé
tel quel (aucun reshufflage d'arguments). Ce wrapper journalise le
déclenchement et capture toute exception avec traceback pour rendre
chaque clic traçable.
pystray invoque les actions avec ``(icon, item)`` et utilise la
signature pour adapter les arguments. Un handler à 2 params est
appelé tel quel, sans reshufflage.
Cette enveloppe journalise chaque clic, délègue la vraie action au
thread Tk principal via ``after()``, et capture toute exception avec
traceback.
"""
def handler(icon, item) -> None:
LOG.debug("Clic menu → %s", label)
try:
action()
self._defer_tk(action)
except Exception:
LOG.exception("Erreur dans l'action de menu : %s", label)
LOG.exception("Impossible de déférer l'action Tk : %s", label)
return handler
def _select_profile_action(self, profile) -> Callable:
def action() -> None:
LOG.debug("Sélection du profil « %s » demandée", profile.name)
self.set_active(profile.name)
self._set_active(profile.name)
return action
# -- cycle de vie -----------------------------------------------------------
def start(self) -> None:
"""Attache le raccourci global puis lance l'icône — BLoquant, sur le
thread principal.
"""Attache le raccourci global puis lance l'icône dans un thread
d'arrière-plan.
pystray exige que ``Icon.run()`` soit appelé depuis le thread principal
; les callbacks de menu s'exécutent alors sur ce thread, ce qui est
indispensable pour créer des fenêtres Tk. Retourne quand « Quitter »
est demandé.
La boucle pystray tourne dans un thread daemon (fonctionne sous
Windows). Le thread principal doit lancer Tk.mainloop() de son côté.
"""
hotkey = self.store.hotkey
try:
@@ -205,24 +215,19 @@ class TrayApp:
icon = self._icon if self._icon is not None else self.build_icon()
self._icon = icon
LOG.info("Application lancée en arrière-plan (icône zone de notification).")
icon.run() # bloque sur le thread principal
LOG.info("Sortie : la boucle de l'icône s'est terminée.")
self._icon_thread = threading.Thread(target=icon.run, daemon=True)
self._icon_thread.start()
LOG.info("Application lancée en arrière-plan (icône zone de notification) [pystray en thread].")
def stop(self, icon=None, item=None) -> None:
LOG.info("Arrêt de l'application.")
if self._root is not None:
try:
self._root.destroy()
except tk.TclError:
pass
if self._icon is not None:
try:
self._icon.stop()
except Exception:
pass
self._exit_event.set()
def wait(self) -> None:
"""Blocage jusqu'à ce que l'application soit arrêtée (élément « Quitter »)."""
self._exit_event.wait()
if self._root is not None:
try:
self._root.quit() # débloque Tk.mainloop()
except tk.TclError:
pass