Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4adb88fd35 | ||
|
|
7fa401b287 | ||
|
|
e07f401b5c | ||
|
|
15eb856bf2 | ||
|
|
2f5c428545 | ||
|
|
e7d13d9ad1 | ||
|
|
05e6515011 | ||
|
|
fa7b037e7f |
+58
-21
@@ -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:]))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user