From e7d13d9ad1bbc436a168dffc013b320beb9be8e1 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 18 Sep 2026 10:45:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(tray):=20handlers=20de=20menu=20=C3=A0=202?= =?UTF-8?q?=20args=20(icon,item)=20=E2=80=94=20le=20lambda=201-arg=20captu?= =?UTF-8?q?rait=20l'ic=C3=B4ne=20au=20lieu=20du=20nom=20du=20profil;=20Ico?= =?UTF-8?q?n.run()=20sur=20le=20thread=20principal=20(exigence=20pystray);?= =?UTF-8?q?=20logs=20DEBUG=20par=20d=C3=A9faut=20+=20formateur=20enrichi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai_typewriter/app.py | 12 +++--- src/ai_typewriter/logging_utils.py | 8 +++- src/ai_typewriter/tray.py | 65 +++++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/src/ai_typewriter/app.py b/src/ai_typewriter/app.py index 37254f6..43028dc 100644 --- a/src/ai_typewriter/app.py +++ b/src/ai_typewriter/app.py @@ -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 diff --git a/src/ai_typewriter/logging_utils.py b/src/ai_typewriter/logging_utils.py index b877acb..19cfdd9 100644 --- a/src/ai_typewriter/logging_utils.py +++ b/src/ai_typewriter/logging_utils.py @@ -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) diff --git a/src/ai_typewriter/tray.py b/src/ai_typewriter/tray.py index 4d6c660..315c44e 100644 --- a/src/ai_typewriter/tray.py +++ b/src/ai_typewriter/tray.py @@ -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.")