Un lanceur Qt pour Windows, et un assemblage partage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+227
@@ -0,0 +1,227 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user