Author SHA1 Message Date
Hermes Agent 4adb88fd35 refactor: subprocess.Popen au lieu de multiprocessing.Process pour les fenêtres Tk
- Nouveau module _tk_window_process.py : point d'entrée pour le subprocess
  (python -m ai_typewriter.ui._tk_window_process). Reçoit les args en
  pickle sur stdin, écrit window.result en pickle sur stdout.
- _tk_spawn.py réécrit avec subprocess.Popen. Payload picklé envoyé sur
  stdin, résultat lu via communicate() dans un thread poller.
- Plus besoin de multiprocessing.Queue ni de start_method.
- PYTHONPATH injecté automatiquement pour que le subprocess trouve le
  package ai_typewriter.
2026-09-18 14:27:35 +02:00
Hermes Agent 7fa401b287 refactor: multiprocessing.Process au lieu de threading.Thread pour les fenêtres Tk
- _tk_spawn.py : chaque fenêtre tourne dans un Process séparé (spawn,
  pas fork) avec sa propre instance tk.Tk. Résultats renvoyés au parent
  via multiprocessing.Queue + thread poller.
- API changée : spawn_tk_window(Class, *args, on_result=..., **kwargs)
  au lieu d'une lambda factory — nécessaire pour la picklabilité.
- SecureStore n'est plus passé aux processus fils (non picklable) ;
  chaque dialogue crée le sien (déjà le comportement par défaut).
- TrayApp allégé : plus de self.secure, plus d'import SecureStore.
2026-09-18 13:56:53 +02:00
Hermes Agent e07f401b5c refactor: un thread par fenêtre Tk, main thread dédié au hotkey
- Nouveau helper ui/_tk_spawn.py : chaque fenêtre Tk dans son propre thread
  avec sa propre racine tk.Tk (plus de racine partagée)
- app.py : thread principal pour le hotkey + attente stop_event,
  thread séparé pour l'icône pystray
- tray.py : suppression de get_root/_defer_tk/_present_window,
  spawn_tk_window pour chaque dialogue, callbacks on_result au lieu
  de wait_window bloquant
2026-09-18 13:25:24 +02:00
Hermes Agent 15eb856bf2 fix(tray): fenêtres invisibles et coche de profil non mise à jour
- Ajoute _present_window() (deiconify/lift/topmost/focus_force) pour que
  les Toplevel enfants d'une racine withdraw() apparaissent bien au
  premier plan sous Windows (logs, profil, ajout, authentification).
- Rend le sous-menu des profils dynamique via un générateur avec items
  radio + checked=, et appelle icon.update_menu() après changement de
  profil pour que la coche suive l'état courant.
2026-09-18 11:00:41 +02:00
Hermes Agent 2f5c428545 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 2026-09-18 10:54:03 +02:00
Hermes Agent e7d13d9ad1 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 2026-09-18 10:45:25 +02:00
Hermes Agent 05e6515011 fix(auth): lire p.credential sur les objets Profile; sortie propre via Event (plus de SystemExit dans le callback pystray) 2026-09-18 10:24:23 +02:00
Hermes Agent fa7b037e7f fix(tray): créer le sous-menu via l'action MenuItem (pystray n'accepte pas submenu=) 2026-09-18 10:17:17 +02:00
6 changed files with 421 additions and 102 deletions
+58 -21
View File
@@ -1,22 +1,33 @@
"""Point d'entrée de l'application.
Par défaut, l'application s'exécute en arrière-plan, sans fenêtre visible,
avec une icône dans la zone de notification (Windows). Deux modes console
restent disponibles pour le développement / le test :
- `python main.py --ask "texte"` : envoie le texte au profil actif et
avec une icône dans la zone de notification. Deux modes console restent
disponibles pour le développement / le test :
- ``python main.py --ask "texte"`` : envoie le texte au profil actif et
imprime la réponse sans intercepter le clavier.
- `python main.py` : lance l'icône de la zone de notification.
- ``python main.py`` : lance l'icône de la zone de notification.
Architecture (processus)
------------------------
* **Processus principal** : capture du raccourci global (Ctrl+Alt+A) et
traitement des requêtes IA. Aucune boucle Tk ne tourne ici.
* **Thread UI (tray)** : icône pystray dans la zone de notification.
* **Fenêtres** : chaque dialogue (logs, profil, authentification, …) est
lancé dans son **propre processus** avec sa **propre instance Tk** racine.
Aucune racine Tk partagée — une fenêtre = un processus = un ``tk.Tk``.
"""
from __future__ import annotations
import argparse
import logging
import signal
import sys
import threading
from .config import ConfigStore, load_config
from .credentials import SecureStore
from .engine import AITypewriterEngine
from .engine import AITypewriterEngine, bind_hotkey
from .logging_utils import setup_logging
LOG = logging.getLogger("ai_typewriter")
@@ -40,7 +51,8 @@ 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)
setup_logging(level=logging.DEBUG)
LOG.debug("Démarrage : args=%s", args)
try:
store = load_config(args.config)
@@ -59,29 +71,54 @@ def main(argv: list[str] | None = None) -> int:
LOG.error("%s", exc)
return 1
return run_tray(store)
return run_background(store, engine)
def run_tray(store: ConfigStore) -> int:
"""Lance l'application en arrière-plan avec l'icône de notification."""
# Import différé pour garantir que --ask / les tests fonctionnent même si
# pystray ou PIL ne sont pas installés / sans affichage graphique.
def run_background(store: ConfigStore, engine: AITypewriterEngine) -> int:
"""Lance l'application en arrière-plan.
- Le thread principal enregistre le raccourci global et attend l'arrêt.
- L'icône de notification (pystray) tourne dans un thread dédié.
- Chaque fenêtre Tk est créée dans son propre processus (voir
``ui._tk_spawn.spawn_tk_window``).
"""
# -- raccourci global (sur le thread principal, via le hook keyboard) -----
hotkey = store.hotkey
try:
bind_hotkey(engine, hotkey)
LOG.info("Raccourci global actif : %s", hotkey)
except Exception as exc:
LOG.exception("Impossible d'enregistrer le raccourci : %s", exc)
# -- icône de notification (thread dédié) --------------------------------
from .tray import TrayApp
app = TrayApp(store)
app.start()
stop_event = threading.Event()
tray = TrayApp(store, stop_event=stop_event)
ui_thread = threading.Thread(target=tray.run, daemon=True, name="ui-tray")
ui_thread.start()
# -- le thread principal reste en vie jusqu'au signal d'arrêt ------------
# SIGINT / SIGTERM → arrêt propre
def _handle_signal(signum, frame):
LOG.info("Signal %s reçu, arrêt…", signum)
stop_event.set()
signal.signal(signal.SIGINT, _handle_signal)
signal.signal(signal.SIGTERM, _handle_signal)
# Maintient le processus en vie ; pystray run() tourne déjà dans un thread.
try:
# Boucle événementielle tant que l'application n'est pas arrêtée.
while True:
import time
time.sleep(3600)
stop_event.wait()
except KeyboardInterrupt:
app.stop()
pass
LOG.info("Arrêt demandé.")
tray.stop()
ui_thread.join(timeout=3)
LOG.info("Application terminée.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
raise SystemExit(main(sys.argv[1:]))
+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)
+150 -78
View File
@@ -8,20 +8,25 @@ 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
------------
* **Thread UI** : exécute ``pystray.Icon.run()`` (boucle GTK / appindicator).
* **Fenêtres** : chaque action du menu crée une fenêtre Tk dans un
**processus** dédié avec sa propre racine ``tk.Tk`` (via
``ui._tk_spawn.spawn_tk_window``). Il n'y a **aucune** racine Tk
partagée.
* **Résultats** : les dialogues qui retournent une valeur (ProfileDialog,
ModelPicker) passent leur résultat via un callback exécuté à la fermeture.
"""
from __future__ import annotations
import logging
import threading
import tkinter as tk
from typing import Callable
from .config import ConfigStore
from .credentials import SecureStore
from .engine import AITypewriterEngine, bind_hotkey
from .ui._tk_spawn import spawn_tk_window
from .ui.auth_dialog import AuthDialog
from .ui.logs_window import LogsWindow
from .ui.profile_dialog import ProfileDialog
@@ -30,71 +35,103 @@ LOG = logging.getLogger("ai_typewriter.tray")
class TrayApp:
"""Encapsule l'icône de zone de notification + l'UI Tk."""
"""Encapsule l'icône de zone de notification (pystray)."""
def __init__(
self,
store: ConfigStore,
secure: SecureStore | None = None,
icon_factory: Callable | None = None,
menu_factory: Callable | None = None,
stop_event: threading.Event | None = None,
) -> None:
self.store = store
self.secure = secure or SecureStore()
self.engine = AITypewriterEngine(store, self.secure)
self._root: tk.Tk | None = None
self._root_lock = threading.Lock()
self._icon = None
self._icon_thread: threading.Thread | None = None
self._icon_factory = icon_factory
self._menu_factory = menu_factory
self._stop_event = stop_event
# -- fenêtre racine cachée (pour les dialogues) --------------------------
# -- cycle de vie -----------------------------------------------------------
def get_root(self) -> tk.Tk:
with self._root_lock:
if self._root is None:
self._root = tk.Tk()
self._root.withdraw()
return self._root
def run(self) -> None:
"""Lance la boucle pystray (bloquant, appelé depuis le thread UI)."""
icon = self._icon if self._icon is not None else self.build_icon()
self._icon = icon
LOG.info("Icône de notification lancée (pystray).")
icon.run()
def stop(self, icon=None, item=None) -> None:
"""Arrête l'icône et signale l'arrêt au thread principal."""
LOG.info("Arrêt de l'icône de notification.")
if self._icon is not None:
try:
self._icon.stop()
except Exception:
pass
if self._stop_event is not None:
self._stop_event.set()
# -- actions du menu -------------------------------------------------------
def show_logs(self) -> None:
LogsWindow(self.get_root())
def _show_logs(self) -> None:
spawn_tk_window(LogsWindow)
def edit_profile(self, name: str | None = None) -> None:
"""Ouvre l'éditeur du profil `name`, ou le profil actif si `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()
dlg = ProfileDialog(self.get_root(), existing=target, secure=self.secure)
self.get_root().wait_window(dlg)
if dlg.result:
def on_done(result: object) -> None:
if result is None:
return
try:
store.upsert(dlg.result)
store.set_active(dlg.result.name)
store.upsert(result)
store.set_active(result.name)
self._refresh_menu()
except Exception as exc:
LOG.exception("Impossible d'enregistrer le profil : %s", exc)
def add_profile(self) -> None:
dlg = ProfileDialog(self.get_root(), existing=None, secure=self.secure)
self.get_root().wait_window(dlg)
if dlg.result:
spawn_tk_window(
ProfileDialog,
existing=target,
on_result=on_done,
)
def _add_profile(self) -> None:
store = self.store
def on_done(result: object) -> None:
if result is None:
return
try:
self.store.upsert(dlg.result)
LOG.info("Profil « %s » ajouté.", dlg.result.name)
store.upsert(result)
LOG.info("Profil « %s » ajouté.", result.name)
self._refresh_menu()
except Exception as exc:
LOG.exception("Impossible d'ajouter le profil : %s", exc)
def set_active(self, name: str) -> None:
spawn_tk_window(
ProfileDialog,
on_result=on_done,
)
def _set_active(self, name: str) -> None:
try:
self.store.set_active(name)
LOG.info("Profil actif : %s", name)
self._refresh_menu()
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 _refresh_menu(self) -> None:
"""Demande à pystray de reconstruire le menu (met à jour les coches)."""
if self._icon is not None:
try:
self._icon.update_menu()
except Exception:
LOG.debug("update_menu() a échoué", exc_info=True)
def _manage_auth(self) -> None:
spawn_tk_window(AuthDialog)
# -- construction de l'icône ----------------------------------------------
@@ -109,24 +146,37 @@ 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))
# 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),
)
for p in self.store.get_all()
]
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 — construit dynamiquement à chaque affichage
# (grâce au callable), de sorte que la coche « ✓ » et la liste des
# profils reflètent toujours l'état courant après update_menu().
profiles_sub = pystray.Menu(self._profile_menu_items)
menu_items.append(
self._menu_item("Modifier le profil", None, submenu=profiles_sub)
)
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)
@@ -137,40 +187,62 @@ class TrayApp:
pystray.Menu(*menu_items),
)
def _profile_menu_items(self):
"""Génère dynamiquement les items du sous-menu des profils.
Appelé par pystray à chaque (re)construction du menu : la coche
(via ``checked=``, un item radio) suit donc toujours le profil actif.
"""
import pystray
for p in self.store.get_all():
yield pystray.MenuItem(
p.name,
self._guard(
f"sélection du profil « {p.name} »",
self._select_profile_action(p),
),
checked=self._make_checked(p.name),
radio=True,
)
def _make_checked(self, profile_name: str) -> Callable:
"""Retourne un prédicat évalué à l'affichage du menu."""
return lambda item: self.store.active_name == profile_name
def _menu_item(self, text: str, action, submenu=None):
import pystray
if submenu is not None:
return pystray.MenuItem(text, None, submenu=submenu)
return pystray.MenuItem(text, action or (lambda icon, item: None))
return pystray.MenuItem(text, submenu)
if action is None:
action = lambda icon, item: None
return pystray.MenuItem(text, action)
# -- cycle de vie -----------------------------------------------------------
def _guard(self, label: str, action: Callable) -> Callable:
"""Enveloppe une action de menu avec journalisation et capture d'erreurs.
def start(self) -> None:
"""Attache le raccourci global et lance l'icône dans un thread."""
hotkey = self.store.hotkey
try:
bind_hotkey(self.engine, hotkey)
LOG.info("Raccourci global actif : %s", hotkey)
except Exception as exc:
LOG.exception("Impossible d'enregistrer le raccourci : %s", exc)
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.
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).")
Contrairement à l'ancienne architecture, on n'a **plus** besoin de
déférer l'exécution vers un thread Tk principal via ``after()`` :
chaque fenêtre tourne dans son propre processus Tk.
"""
def stop(self, icon=None, item=None) -> None:
LOG.info("Arrêt de l'application.")
if self._root is not None:
def handler(icon, item) -> None:
LOG.debug("Clic menu → %s", label)
try:
self._root.destroy()
except tk.TclError:
pass
if self._icon is not None:
try:
self._icon.stop()
action()
except Exception:
pass
raise SystemExit(0)
LOG.exception("Erreur lors de l'action : %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
+120
View File
@@ -0,0 +1,120 @@
"""Helper to spawn Tk windows in dedicated **subprocesses** with their own Tk root.
Each call to ``spawn_tk_window()`` launches a fresh Python interpreter via
``subprocess.Popen`` that runs ``ai_typewriter.ui._tk_window_process``.
The child creates its own ``tk.Tk`` (withdrawn), instantiates the window
as a ``tk.Toplevel`` of that root, and runs the Tk event loop. When the
toplevel is closed the root quits, the child exits, and the optional
*on_result* callback is invoked **in the parent process** with
``window.result``.
Communication
-------------
* **Parent → child** : pickled payload written to the child's **stdin**,
containing the fully-qualified class path, positional args, and keyword
args. Stdin is closed immediately after writing.
* **Child → parent** : pickled ``window.result`` written to the child's
**stdout** just before exit. The parent reads it via ``communicate()``
in a background poller thread so the caller is never blocked.
Why subprocess instead of multiprocessing
-----------------------------------------
* No ``multiprocessing`` import overhead or start-method constraints.
* Clean, debuggable separation: the child is a standalone ``python -m``
invocation.
* No pickling restrictions on function targets — the child imports the
window class by its module path.
* ``SecureStore`` instances are **not** passed across the boundary; each
child process creates its own (the dialog classes already default to
``SecureStore()`` when ``secure=None``).
"""
from __future__ import annotations
import os
import pickle
import subprocess
import sys
import threading
from typing import Any, Callable
def spawn_tk_window(
window_class: type,
*win_args: Any,
on_result: Callable[[object], None] | None = None,
**win_kwargs: Any,
) -> subprocess.Popen:
"""Spawn *window_class* in a dedicated **subprocess** with its own Tk root.
Parameters
----------
window_class:
A ``tk.Toplevel`` subclass (defined at module level). Its
``__init__`` must accept a ``parent`` (``tk.Tk``) as the first
positional argument.
*win_args:
Extra positional arguments forwarded to the window constructor
(after the parent).
on_result:
If provided, called **in the parent process** (on a short-lived
poller thread) with the value of ``window.result`` once the
window closes. ``None`` is passed when the attribute is absent
or the child exits abnormally.
**win_kwargs:
Keyword arguments forwarded to the window constructor.
Returns
-------
The ``subprocess.Popen`` instance for the child process.
"""
# -- build the payload ---------------------------------------------------
payload = {
"class": f"{window_class.__module__}.{window_class.__qualname__}",
"args": win_args,
"kwargs": win_kwargs,
}
payload_bytes = pickle.dumps(payload)
# -- ensure the child can find the ai_typewriter package -----------------
env = os.environ.copy()
src_dir = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
existing = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = f"{src_dir}{os.pathsep}{existing}" if existing else src_dir
# -- launch the child ----------------------------------------------------
proc = subprocess.Popen(
[sys.executable, "-m", "ai_typewriter.ui._tk_window_process"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
# Send the payload and close stdin so the child knows it's complete.
try:
proc.stdin.write(payload_bytes) # type: ignore[union-attr]
proc.stdin.close() # type: ignore[union-attr]
except BrokenPipeError:
# Child exited before reading — nothing to do.
pass
# -- background result collection ----------------------------------------
if on_result is not None:
def _poll() -> None:
try:
stdout_data, stderr_data = proc.communicate()
if stdout_data:
result = pickle.loads(stdout_data)
else:
result = None
on_result(result)
except Exception:
on_result(None)
threading.Thread(target=_poll, daemon=True, name="tk-result-poller").start()
return proc
@@ -0,0 +1,86 @@
"""Entry point executed by ``subprocess.Popen`` to run a single Tk window.
Reads a pickled payload from **stdin** that describes the window class to
instantiate, creates a fresh ``tk.Tk`` + ``tk.Toplevel``, runs the Tk
event loop, and writes the pickled ``window.result`` back to **stdout**
before exiting.
This module is designed to be invoked as::
python -m ai_typewriter.ui._tk_window_process
It is **not** imported by the parent process.
"""
from __future__ import annotations
import importlib
import pickle
import sys
import tkinter as tk
def main() -> None:
"""Read args from stdin, run the Tk window, write result to stdout."""
# -- decode the payload from stdin ---------------------------------------
payload = pickle.loads(sys.stdin.buffer.read())
mod_path, cls_name = payload["class"].rsplit(".", 1)
win_args: tuple = payload.get("args", ())
win_kwargs: dict = payload.get("kwargs", {})
# -- import the window class dynamically ---------------------------------
mod = importlib.import_module(mod_path)
window_class = getattr(mod, cls_name)
# -- create the Tk root + window ----------------------------------------
root = tk.Tk()
root.withdraw()
try:
win = window_class(root, *win_args, **win_kwargs)
except Exception:
root.quit()
raise
_closed = False
_result: object = None
def _on_destroy(event: tk.Event) -> None:
nonlocal _closed, _result
if event.widget is not win:
return
if _closed:
return
_closed = True
try:
if hasattr(win, "result"):
_result = win.result
except Exception:
pass
try:
root.quit()
except tk.TclError:
pass
win.bind("<Destroy>", _on_destroy)
win.protocol("WM_DELETE_WINDOW", lambda: win.destroy())
# -- present the window --------------------------------------------------
try:
win.deiconify()
win.lift()
win.attributes("-topmost", True)
win.after(200, lambda: win.attributes("-topmost", False))
win.focus_force()
except tk.TclError:
pass
root.mainloop()
# -- write the result back to the parent via stdout ----------------------
sys.stdout.buffer.write(pickle.dumps(_result))
sys.stdout.buffer.flush()
if __name__ == "__main__":
main()
+1 -1
View File
@@ -84,7 +84,7 @@ class AuthDialog(tk.Toplevel):
# typiques et on laisse l'utilisateur vérifier leur existence.
known = sorted(
set(self._known)
| {p.get("credential", "") for p in self._known_profiles()}
| {p.credential for p in self._known_profiles() if p.credential}
)
known = [k for k in known if k]
for k in known: