fix(tray): handlers de menu à 2 args (icon,item) — le lambda 1-arg capturait l'icône au lieu du nom du profil; Icon.run() sur le thread principal (exigence pystray); logs DEBUG par défaut + formateur enrichi

This commit is contained in:
Hermes Agent
2026-09-18 10:45:25 +02:00
parent 05e6515011
commit e7d13d9ad1
3 changed files with 68 additions and 17 deletions
+6 -6
View File
@@ -40,7 +40,9 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
setup_logging(level=logging.DEBUG if args.debug else logging.INFO)
# Journalisation très verbeuse par défaut pour faciliter le diagnostic.
setup_logging(level=logging.DEBUG)
LOG.debug("Démarrage : args=%s", args)
try:
store = load_config(args.config)
@@ -69,12 +71,10 @@ def run_tray(store: ConfigStore) -> int:
from .tray import TrayApp
app = TrayApp(store)
app.start()
# Maintient le processus en vie ; pystray run() tourne déjà dans un thread.
# On attend l'arrêt déclenché par l'élément « Quitter » (via un Event).
try:
app.wait()
# start() bloque (Icon.run() sur le thread principal) et retourne
# quand l'utilisateur clique sur « Quitter ».
app.start()
except KeyboardInterrupt:
app.stop()
return 0
+6 -2
View File
@@ -73,12 +73,16 @@ def setup_file_logging(level: int = logging.INFO) -> Path:
handler = logging.handlers.RotatingFileHandler(
path, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8"
)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
handler.setFormatter(
logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s [tid:%(thread)d:%(threadName)s] %(message)s"
)
)
logging.getLogger().addHandler(handler)
return path
def setup_logging(level: int = logging.INFO) -> Path:
def setup_logging(level: int = logging.DEBUG) -> Path:
broadcaster().install(level)
return setup_file_logging(level)
+56 -9
View File
@@ -110,14 +110,21 @@ class TrayApp:
return img
menu_items = []
menu_items.append(self._menu_item("Ouvrir les logs", self.show_logs))
menu_items.append(self._menu_item("Ajouter un profil", self.add_profile))
menu_items.append(
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))
)
# Sous-menu des profils
profiles_sub = pystray.Menu(
*[
self._menu_item(
p.name + (" ✓" if p.name == self.store.active_name else ""),
lambda n=p.name: self.set_active(n),
self._guard(
f"sélection du profil « {p.name} »",
self._select_profile_action(p),
),
)
for p in self.store.get_all()
]
@@ -125,9 +132,14 @@ class TrayApp:
menu_items.append(self._menu_item("Modifier le profil", None, submenu=profiles_sub))
menu_items.append(pystray.Menu.SEPARATOR)
menu_items.append(self._menu_item("Gérer l'authentification", self.manage_auth))
menu_items.append(
self._menu_item(
"Gérer l'authentification",
self._guard("Gérer l'authentification", self.manage_auth),
)
)
menu_items.append(pystray.Menu.SEPARATOR)
menu_items.append(self._menu_item("Quitter", self.stop))
menu_items.append(self._menu_item("Quitter", self._guard("Quitter", self.stop)))
if self._menu_factory:
return self._menu_factory(_image, menu_items)
@@ -143,12 +155,47 @@ class TrayApp:
if submenu is not None:
return pystray.MenuItem(text, submenu)
return pystray.MenuItem(text, action or (lambda icon, item: None))
if action is None:
action = lambda icon, item: None
return pystray.MenuItem(text, action)
def _guard(self, label: str, action: Callable) -> Callable:
"""Enveloppe une action de menu (sans argument requis par l'appelant).
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.
"""
def handler(icon, item) -> None:
LOG.debug("Clic menu → %s", label)
try:
action()
except Exception:
LOG.exception("Erreur dans l'action de menu : %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)
return action
# -- cycle de vie -----------------------------------------------------------
def start(self) -> None:
"""Attache le raccourci global et lance l'icône dans un thread."""
"""Attache le raccourci global puis lance l'icône — BLoquant, sur le
thread principal.
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é.
"""
hotkey = self.store.hotkey
try:
bind_hotkey(self.engine, hotkey)
@@ -158,9 +205,9 @@ class TrayApp:
icon = self._icon if self._icon is not None else self.build_icon()
self._icon = icon
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).")
icon.run() # bloque sur le thread principal
LOG.info("Sortie : la boucle de l'icône s'est terminée.")
def stop(self, icon=None, item=None) -> None:
LOG.info("Arrêt de l'application.")