"""Point d'entrée : application Qt de barre de tâches (PySide6, Windows). Pendant Qt de `app.py` (Cocoa/macOS) : même icône de barre (🎙️/🔴/⏳), même menu à deux entrées (Sons, Quitter), même hotkey globale via pynput. Ce qui diffère de Cocoa est ISOLÉ ici — boucle d'événements Qt, `QSystemTrayIcon`, marshalage par signal Qt — le reste (choix du système, verrou d'instance, transcripteur, overlay, contrôleur) vient d'`assemblage.py`, partagé avec `app.py`. Dupliquer cet assemblage créerait deux copies vouées à diverger en silence (cf docstring d'`assemblage.py`). Marshalage thread de fond → thread Qt : `AppHelper.callAfter` (Cocoa) n'a pas d'équivalent Qt. La voie sûre est un signal Qt émis depuis le thread de fond et connecté en `QueuedConnection` — Qt place alors l'exécution du slot dans la boucle d'événements du thread PROPRIÉTAIRE de l'objet récepteur (le thread principal, puisque `_Pont` y est instancié), quel que soit le thread émetteur. `QTimer.singleShot` depuis un thread non-Qt n'offre pas cette garantie : un QTimer démarré hors du thread principal s'exécute (ou pas) sur CE thread, pas sur le thread principal — comportement non documenté comme sûr, à l'inverse des connexions en file, documentées explicitement thread-safe. """ import sys # --diagnostic doit s'évaluer AVANT tout import PySide6/pynput : sur une # machine qui ne les a pas, c'est le seul moyen d'éprouver la chaîne (même # raison que dans app.py — voir sa docstring). if "--diagnostic" in sys.argv: from diagnostic import diagnostiquer, mettre_en_forme print(mettre_en_forme(diagnostiquer())) sys.exit(0) import os import threading from PySide6.QtCore import QObject, Qt, Signal from PySide6.QtGui import QAction, QIcon, QPixmap from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon from pynput import keyboard from assemblage import acquerir_verrou, construire from core import CONFIG from systeme import choisir_systeme _STATE_TITLES = { "idle": "🎙️", "recording": "🔴", "transcribing": "⏳", } def _icone_pour(emoji): """Rend un QIcon dessiné à partir d'un emoji — pas de fichier .ico à livrer.""" pixmap = QPixmap(64, 64) pixmap.fill(Qt.GlobalColor.transparent) from PySide6.QtGui import QFont, QPainter with QPainter(pixmap) as painter: painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) font = QFont() font.setPointSize(40) painter.setFont(font) painter.drawText(pixmap.rect(), int(Qt.AlignmentFlag.AlignCenter), emoji) return QIcon(pixmap) class _Pont(QObject): """Fait passer un appel du thread de fond (hotkey, contrôleur) au thread Qt principal. `etat_change` porte le nom d'état ("idle"/"recording"/ "transcribing") ; `appel_demande` porte une fonction à exécuter sans argument (utilisée pour `controller.toggle`). Émettre un signal Qt depuis un thread quelconque est thread-safe par construction ; c'est la connexion en Qt.QueuedConnection (posée à la construction de `ZonzaTray`) qui garantit que le SLOT, lui, s'exécute sur le thread principal. """ etat_change = Signal(str) appel_demande = Signal(object) class ZonzaTray: """Icône de barre de tâches + menu + hotkey + contrôleur, côté Qt.""" def __init__(self, controller, systeme): self.controller = controller self.systeme = systeme self._listener = None self._pont = _Pont() self._pont.etat_change.connect( self._apply_title, Qt.ConnectionType.QueuedConnection ) self._pont.appel_demande.connect( lambda fn: fn(), Qt.ConnectionType.QueuedConnection ) self._current_state = "idle" self.tray = QSystemTrayIcon(_icone_pour(_STATE_TITLES["idle"])) self.tray.setToolTip("Zonza") self.menu = QMenu() self.sounds_action = QAction("Sons", self.menu) self.sounds_action.setCheckable(True) self.sounds_action.setChecked(bool(CONFIG["sounds"])) self.sounds_action.triggered.connect(self.toggle_sounds) self.menu.addAction(self.sounds_action) self.menu.addSeparator() self.quit_action = QAction("Quitter", self.menu) self.quit_action.triggered.connect(self.quit_app) self.menu.addAction(self.quit_action) self.tray.setContextMenu(self.menu) def run_on_main(self, fn): """Passé au contrôleur comme `run_on_main` : marshale `fn` sur le thread Qt.""" self._pont.appel_demande.emit(fn) def on_state(self, state): """Passé au contrôleur comme `on_state` : peut arriver d'un thread de fond.""" self._pont.etat_change.emit(state) def _apply_title(self, state): # `_current_state` n'est pas utilisé pour dessiner (l'icône vient de # `_STATE_TITLES` juste en dessous) : il existe pour que les tests # puissent vérifier la correspondance état → icône sans avoir à # comparer deux QIcon pixel à pixel. self._current_state = state self.tray.setIcon(_icone_pour(_STATE_TITLES.get(state, "🎙️"))) def show(self): self.tray.show() self._start_hotkey() self._warmup_async() # --- actions du menu ------------------------------------------------ def toggle_sounds(self, checked): CONFIG["sounds"] = bool(checked) self.sounds_action.setChecked(CONFIG["sounds"]) def quit_app(self): if self._listener is not None: self._listener.stop() QApplication.instance().quit() # --- hotkey globale --------------------------------------------------- def _start_hotkey(self): # pynput exécute ce callback sur SON thread d'écoute, pas le thread # Qt principal. toggle() touche l'overlay (Qt) → marshalage requis, # même raison que app.py avec AppHelper.callAfter. def on_activate(): self.run_on_main(self.controller.toggle) self._listener = keyboard.GlobalHotKeys( {self.systeme.raccourci_defaut(): on_activate} ) self._listener.start() # --- warmup modèle ---------------------------------------------------- def _warmup_async(self): def run(): try: self.controller.transcripteur.warmup() except Exception as e: print(f"[erreur] warmup modèle : {e}") threading.Thread(target=run, daemon=True).start() def _rediriger_sortie_vers_le_journal(chemin): """Renvoie stdout/stderr vers `chemin`. Sur macOS, c'est `launcher/stub.c` (freopen) qui s'en charge AVANT même de lancer Python, parce que le bundle n'a pas de terminal. Ce lanceur n'a pas de stub équivalent sur Windows (lancé directement, pas via un exécutable C) : c'est donc lui qui doit faire cette redirection, le plus tôt possible dans main(), avant le premier print. """ parent = os.path.dirname(chemin) if parent: os.makedirs(parent, exist_ok=True) journal = open(chemin, "a", buffering=1) # ligne par ligne, comme setvbuf(_IOLBF) côté stub sys.stdout = journal sys.stderr = journal # Variable d'environnement permettant de faire tourner ce lanceur SANS toucher # aux chemins réels de l'utilisateur (verrou d'instance, journal) : c'est ce # qui rend possible de le lancer manuellement à côté d'une instance Zonza # déjà installée, pour une vérification, sans lui disputer son verrou ni # mélanger les journaux. Absente (cas normal) : les chemins réels du système # sont utilisés, comme app.py. _ENV_DOSSIER_DONNEES = "ZONZA_DOSSIER_DONNEES_TEST" def main(): app = QApplication(sys.argv) app.setQuitOnLastWindowClosed(False) # la fenêtre, c'est l'icône de la barre systeme = choisir_systeme() dossier_test = os.environ.get(_ENV_DOSSIER_DONNEES) if dossier_test: chemin_verrou = os.path.join(dossier_test, "instance.lock") chemin_log = os.path.join(dossier_test, "Zonza.log") else: chemin_verrou = None # None -> systeme.dossier_donnees(), le chemin réel chemin_log = systeme.chemin_log() _rediriger_sortie_vers_le_journal(chemin_log) lock = acquerir_verrou(systeme, chemin_verrou) if lock is None: print("[garde] une autre instance de Zonza tourne deja — arret.") return main._lock = lock # garder le verrou vivant tant que le process vit # Dépendance circulaire : le contrôleur a besoin de run_on_main/on_state # du tray, mais le tray a besoin du contrôleur pour être construit. On la # casse avec une case remplie APRÈS coup : les deux fermetures ci-dessous # ne lisent `case["tray"]` qu'au moment de l'appel, jamais à la # construction — ce qui laisse le temps de la remplir. case = {} assemblage = construire( run_on_main=lambda fn: case["tray"].run_on_main(fn), on_state=lambda s: case["tray"].on_state(s), ) tray = ZonzaTray(assemblage["controller"], assemblage["systeme"]) case["tray"] = tray tray.show() main._tray = tray # référence gardée pour toute la vie du processus sys.exit(app.exec()) if __name__ == "__main__": main()