zonza/lanceur_qt.py
Ralph Mayola aa63e94239 Un lanceur Qt pour Windows, et un assemblage partage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:00:03 +02:00

228 lines
9.1 KiB
Python

"""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()