228 lines
9.1 KiB
Python
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()
|