Compare commits

...

2 Commits

Author SHA1 Message Date
332afede0e Plan : corriger deux defauts releves a l'auto-relecture
dossier_donnees() etait utilise par app.py sans figurer dans le contrat Systeme —
l'implementation Windows aurait pu l'omettre et ne casser qu'a l'execution. Et les
comptes de tests annonces ne s'additionnaient pas depuis 80.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:44:39 +02:00
372df70c62 Plan : etape 1 du portage multiplateforme
Sept taches, chacune en TDD. Point de vigilance principal : app.py garde son nom
pour que le cdhash du stub reste identique et que l'autorisation d'Accessibilite
survive au refactor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:43:39 +02:00

View File

@ -0,0 +1,823 @@
# Zonza multiplateforme — Étape 1 : extraire les contrats
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Nommer les trois frontières (moteur, interface, système) derrière des contrats explicites, sans changer un seul comportement.
**Architecture:** `controller.py` reçoit ses collaborateurs par injection au lieu de les importer. Le code Cocoa est **déplacé sans être modifié**. Une fabrique par frontière choisit l'implémentation ; sur ce Mac, elle rend exactement ce qui tourne aujourd'hui.
**Tech Stack:** Python 3.14, pytest, pyobjc (inchangé), sounddevice, pynput, mlx-whisper.
**Critère de réussite :** les 80 tests restent verts, `./build_app.sh` produit un stub au **cdhash identique**, et Zonza dicte comme avant.
---
## Contrainte non négociable
`build_app.sh:43` grave `-DAPP_PY="$HERE/app.py"` dans le stub C. **Renommer ou déplacer `app.py` change le binaire, donc son cdhash, donc révoque l'autorisation d'Accessibilité de macOS** (vérifié le 2026-08-21). `app.py` reste donc à la racine sous ce nom — la spec parlait de `lanceur.py`, on s'en écarte volontairement.
Vérification à faire à la fin de chaque tâche qui touche au build :
```bash
codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash='
```
## Structure de fichiers
| Fichier | Responsabilité |
|---|---|
| `contrats.py` | **créer** — les trois protocoles, et rien d'autre |
| `systeme/__init__.py` | **créer**`choisir_systeme()` |
| `systeme/macos.py` | **créer** — son, modificateurs, journal, verrou, raccourci |
| `moteur/__init__.py` | **créer**`choisir_transcripteur()` |
| `moteur/mlx.py` | **créer**`TranscripteurMLX`, extrait de `engine.Transcriber` |
| `interface/__init__.py` | **créer**`choisir_interface()` |
| `interface/cocoa/overlay.py` | **créer**`pulse_window.py` déplacé **tel quel** |
| `capture.py` | **créer**`Recorder` et `inject_text`, déjà portables |
| `engine.py` | **supprimer** en fin de parcours |
| `pulse_window.py` | **supprimer** en fin de parcours |
| `controller.py` | **modifier** — injection au lieu d'imports |
| `app.py` | **modifier** — câblage, nom inchangé |
| `tests/test_contrats.py` | **créer** — suite rejouée contre chaque implémentation |
---
## Task 1 : Les contrats
**Files:**
- Create: `contrats.py`
- Test: `tests/test_contrats.py`
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/test_contrats.py
"""Une suite unique, rejouée contre TOUTES les implémentations d'un contrat.
Le jour où une implémentation oublie une méthode, c'est ici que ça rougit — y
compris pour les plateformes absentes de la machine de développement.
"""
import inspect
import pytest
from contrats import Overlay, Systeme, Transcripteur
@pytest.mark.parametrize("contrat, methodes", [
(Transcripteur, ["warmup", "transcrire"]),
(Overlay, ["show", "hide", "set_level", "set_status", "rearmer_garde"]),
(Systeme, ["jouer_son", "modificateurs_enfonces", "raccourci_defaut",
"chemin_log", "dossier_donnees", "acquerir_verrou_instance"]),
])
def test_le_contrat_declare_exactement_ses_methodes(contrat, methodes):
declarees = [n for n, _ in inspect.getmembers(contrat, inspect.isfunction)
if not n.startswith("_")]
assert sorted(declarees) == sorted(methodes)
def test_les_contrats_sont_documentes():
for contrat in (Transcripteur, Overlay, Systeme):
assert contrat.__doc__, f"{contrat.__name__} doit dire à quoi il sert"
```
- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue**
Run: `.venv/bin/python -m pytest tests/test_contrats.py -q`
Expected: FAIL — `ModuleNotFoundError: No module named 'contrats'`
- [ ] **Step 3 : Écrire l'implémentation minimale**
```python
# contrats.py
"""Les trois frontières de Zonza.
Chaque contrat est délibérément minuscule : ce sont les seuls points par lesquels
le noyau parle au monde extérieur. Tout ce qui dépend d'un système d'exploitation,
d'une bibliothèque d'affichage ou d'un moteur de transcription vit DERRIÈRE l'un
d'eux — jamais dans `core.py`, `controller.py` ni `audio_level.py`.
"""
from typing import Protocol
class Transcripteur(Protocol):
"""Transforme un signal audio en texte."""
def warmup(self) -> None:
"""Charge le modèle une fois, pour que la première dictée ne traîne pas."""
def transcrire(self, audio) -> str:
"""Rend le texte d'un tableau numpy float32 mono."""
class Overlay(Protocol):
"""La fenêtre flottante affichée pendant la dictée."""
def show(self) -> None:
"""Affiche la fenêtre et démarre l'animation. Thread principal."""
def hide(self) -> None:
"""Masque la fenêtre et arrête l'animation. Thread principal."""
def set_level(self, level: float) -> None:
"""Pousse un niveau audio dans [0, 1]. Appelable depuis un thread de fond."""
def set_status(self, text: str) -> None:
"""Texte affiché dans la bulle. Appelable depuis un thread de fond."""
def rearmer_garde(self, duree_s: float) -> None:
"""Remplace la durée de vie maximale de la fenêtre. Thread principal."""
class Systeme(Protocol):
"""Les appels qui diffèrent d'un système d'exploitation à l'autre."""
def jouer_son(self, nom: str) -> None:
"""Joue « start », « done » ou « error » sans bloquer."""
def modificateurs_enfonces(self) -> bool:
"""True si une touche modificatrice est physiquement enfoncée."""
def raccourci_defaut(self) -> str:
"""Raccourci global au format pynput, propre à la plateforme."""
def chemin_log(self) -> str:
"""Chemin absolu du journal."""
def dossier_donnees(self) -> str:
"""Dossier des données de l'application (verrou d'instance, réglages)."""
def acquerir_verrou_instance(self, chemin: str):
"""Verrou exclusif non bloquant ; rend le fichier, ou None s'il est pris."""
```
- [ ] **Step 4 : Lancer les tests**
Run: `.venv/bin/python -m pytest tests/test_contrats.py -q`
Expected: PASS — 4 tests
- [ ] **Step 5 : Commit**
```bash
git add contrats.py tests/test_contrats.py
git commit -m "Declare les trois contrats de Zonza"
```
---
## Task 2 : La couche système macOS
**Files:**
- Create: `systeme/__init__.py`, `systeme/macos.py`
- Modify: `core.py` (retirer `acquire_single_instance_lock` et `LOCK_PATH`)
- Test: `tests/test_systeme.py`, `tests/test_single_instance.py`
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/test_systeme.py
"""La fabrique rend une implémentation conforme au contrat Systeme."""
import os
import tempfile
import pytest
from contrats import Systeme
from systeme import choisir_systeme
def test_la_fabrique_rend_une_implementation_complete():
s = choisir_systeme()
for methode in ("jouer_son", "modificateurs_enfonces", "raccourci_defaut",
"chemin_log", "dossier_donnees", "acquerir_verrou_instance"):
assert callable(getattr(s, methode)), f"{methode} manque"
def test_une_plateforme_inconnue_est_refusee_clairement():
with pytest.raises(NotImplementedError) as e:
choisir_systeme(plateforme="haiku-os")
assert "haiku-os" in str(e.value)
def test_le_raccourci_macos_est_celui_d_aujourd_hui():
assert choisir_systeme(plateforme="darwin").raccourci_defaut() == "<cmd>+<ctrl>+d"
def test_le_verrou_refuse_une_seconde_prise():
s = choisir_systeme()
chemin = os.path.join(tempfile.mkdtemp(), "sous-dossier", "z.lock")
assert s.acquerir_verrou_instance(chemin) is not None
assert s.acquerir_verrou_instance(chemin) is None
def test_aucun_modificateur_enfonce_pendant_les_tests():
assert choisir_systeme().modificateurs_enfonces() is False
```
- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue**
Run: `.venv/bin/python -m pytest tests/test_systeme.py -q`
Expected: FAIL — `ModuleNotFoundError: No module named 'systeme'`
- [ ] **Step 3 : Écrire l'implémentation**
```python
# systeme/macos.py
"""Appels propres à macOS. Rien ici ne doit être importé directement par le noyau."""
import fcntl
import os
import subprocess
_SONS = {
"start": "/System/Library/Sounds/Pop.aiff",
"done": "/System/Library/Sounds/Glass.aiff",
"error": "/System/Library/Sounds/Basso.aiff",
}
# Masque des modificateurs (ctrl, shift, alt, cmd) dans les drapeaux Quartz.
_MASQUE_MODIFICATEURS = 0x000F0000
class SystemeMacOS:
"""Implémentation du contrat Systeme pour macOS."""
def jouer_son(self, nom):
chemin = _SONS.get(nom)
if not chemin:
return
try:
subprocess.Popen(["afplay", chemin])
except Exception:
pass # le son ne doit jamais casser la dictée
def modificateurs_enfonces(self):
try:
from Quartz import CGEventSourceFlagsState
return bool(CGEventSourceFlagsState(1) & _MASQUE_MODIFICATEURS)
except Exception:
return False # dans le doute, ne jamais bloquer le collage
def raccourci_defaut(self):
return "<cmd>+<ctrl>+d"
def chemin_log(self):
return os.path.expanduser("~/Library/Logs/Zonza.log")
def dossier_donnees(self):
return os.path.expanduser("~/Library/Application Support/Zonza")
def acquerir_verrou_instance(self, chemin):
parent = os.path.dirname(chemin)
if parent:
os.makedirs(parent, exist_ok=True)
fichier = open(chemin, "a")
try:
fcntl.flock(fichier, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
fichier.close()
return None
return fichier
```
```python
# systeme/__init__.py
"""Choisit la couche système selon la plateforme."""
import sys
def choisir_systeme(plateforme=None):
"""Rend l'implémentation du contrat Systeme pour cette machine.
`plateforme` est injectable pour tester les branches absentes de la machine
de développement.
"""
plateforme = plateforme or sys.platform
if plateforme.startswith("darwin"):
from systeme.macos import SystemeMacOS
return SystemeMacOS()
raise NotImplementedError(f"Plateforme non prise en charge : {plateforme}")
```
- [ ] **Step 4 : Retirer le verrou de `core.py`**
Supprimer de `core.py` : l'import `fcntl`, la constante `LOCK_PATH` et la fonction `acquire_single_instance_lock` entière. Garder `import os`, encore utilisé par le reste du fichier.
- [ ] **Step 5 : Réécrire `tests/test_single_instance.py` pour viser la couche système**
Remplacer la ligne d'import :
```python
from systeme import choisir_systeme
```
et, dans chaque test, remplacer `acquire_single_instance_lock(...)` par `choisir_systeme().acquerir_verrou_instance(...)`. Les assertions ne changent pas.
- [ ] **Step 6 : Lancer toute la suite**
Run: `.venv/bin/python -m pytest -q`
Expected: PASS — **89 tests** (80 au depart, +4 en tache 1, +5 ici)
- [ ] **Step 7 : Commit**
```bash
git add systeme contrats.py core.py tests/test_systeme.py tests/test_single_instance.py
git commit -m "Extrait les appels macOS derriere le contrat Systeme"
```
---
## Task 3 : Le moteur de transcription
**Files:**
- Create: `moteur/__init__.py`, `moteur/mlx.py`
- Test: `tests/test_moteur.py`
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/test_moteur.py
"""La fabrique choisit le moteur d'après le matériel — décision pure, testable
depuis n'importe quelle machine."""
import pytest
from moteur import choisir_transcripteur, nom_moteur_pour
@pytest.mark.parametrize("plateforme, machine, cuda, attendu", [
("darwin", "arm64", False, "mlx"),
("darwin", "x86_64", False, "faster-cpu"),
("win32", "AMD64", True, "faster-gpu"),
("win32", "AMD64", False, "faster-cpu"),
])
def test_la_decision_suit_le_materiel(plateforme, machine, cuda, attendu):
assert nom_moteur_pour(plateforme, machine, cuda) == attendu
def test_apple_silicon_prend_mlx_meme_sans_cuda():
assert nom_moteur_pour("darwin", "arm64", cuda_disponible=False) == "mlx"
def test_la_fabrique_rend_un_transcripteur_complet():
t = choisir_transcripteur(nom="mlx", modele="mlx-community/whisper-medium",
langue="fr", amorce="")
assert callable(t.warmup) and callable(t.transcrire)
def test_un_moteur_inconnu_est_refuse_clairement():
with pytest.raises(NotImplementedError) as e:
choisir_transcripteur(nom="vosk", modele="x", langue="fr", amorce="")
assert "vosk" in str(e.value)
```
- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue**
Run: `.venv/bin/python -m pytest tests/test_moteur.py -q`
Expected: FAIL — `ModuleNotFoundError: No module named 'moteur'`
- [ ] **Step 3 : Écrire l'implémentation**
```python
# moteur/mlx.py
"""Transcription par MLX-Whisper — GPU Apple Silicon uniquement."""
import numpy as np
from core import clean_transcript
class TranscripteurMLX:
"""Implémentation du contrat Transcripteur pour Apple Silicon."""
def __init__(self, modele, langue, amorce):
self.modele = modele
self.langue = langue
self.amorce = amorce
def warmup(self):
"""Charge le modèle une fois, sur une seconde de silence."""
self.transcrire(np.zeros(16000, dtype="float32"))
def transcrire(self, audio):
import mlx_whisper
resultat = mlx_whisper.transcribe(
audio,
path_or_hf_repo=self.modele,
language=self.langue,
initial_prompt=self.amorce,
)
return clean_transcript(resultat.get("text", ""))
```
```python
# moteur/__init__.py
"""Choisit le moteur de transcription d'après le matériel."""
import platform
import sys
def nom_moteur_pour(plateforme, machine, cuda_disponible):
"""Décide du moteur. Fonction PURE : aucune interrogation de la machine ici,
ce qui rend les quatre configurations testables depuis un seul poste."""
if plateforme.startswith("darwin") and machine in ("arm64", "aarch64"):
return "mlx"
return "faster-gpu" if cuda_disponible else "faster-cpu"
def detecter_moteur():
"""Interroge la machine, puis délègue la décision à `nom_moteur_pour`."""
return nom_moteur_pour(sys.platform, platform.machine(), _cuda_disponible())
def _cuda_disponible():
try:
import ctypes
ctypes.CDLL("libcudart.so")
return True
except Exception:
return False
def choisir_transcripteur(nom, modele, langue, amorce):
"""Rend l'implémentation du contrat Transcripteur portant ce nom."""
if nom == "mlx":
from moteur.mlx import TranscripteurMLX
return TranscripteurMLX(modele, langue, amorce)
raise NotImplementedError(f"Moteur non pris en charge à cette étape : {nom}")
```
- [ ] **Step 4 : Lancer les tests**
Run: `.venv/bin/python -m pytest tests/test_moteur.py -q`
Expected: PASS — 7 tests
- [ ] **Step 5 : Commit**
```bash
git add moteur tests/test_moteur.py
git commit -m "Extrait la transcription derriere le contrat Transcripteur"
```
---
## Task 4 : Déplacer l'interface Cocoa sans la modifier
**Files:**
- Create: `interface/__init__.py`, `interface/cocoa/__init__.py`, `interface/cocoa/overlay.py`
- Delete: `pulse_window.py`
- Test: `tests/test_interface.py`
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/test_interface.py
"""La fabrique rend un overlay conforme au contrat, sans ouvrir de fenêtre."""
import pytest
from interface import classe_overlay_pour
def test_l_overlay_macos_declare_tout_le_contrat():
classe = classe_overlay_pour("darwin")
for methode in ("show", "hide", "set_level", "set_status", "rearmer_garde"):
assert callable(getattr(classe, methode, None)), f"{methode} manque"
def test_une_plateforme_inconnue_est_refusee_clairement():
with pytest.raises(NotImplementedError) as e:
classe_overlay_pour("haiku-os")
assert "haiku-os" in str(e.value)
```
- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue**
Run: `.venv/bin/python -m pytest tests/test_interface.py -q`
Expected: FAIL — `ModuleNotFoundError: No module named 'interface'`
- [ ] **Step 3 : Déplacer le fichier SANS toucher à son contenu**
```bash
mkdir -p interface/cocoa
git mv pulse_window.py interface/cocoa/overlay.py
touch interface/cocoa/__init__.py
```
Ne modifier **aucune ligne** du corps de `overlay.py`. Ses imports `from audio_level import ...` et `from core import ...` continuent de fonctionner : `conftest.py` place la racine du projet sur `sys.path`.
- [ ] **Step 4 : Écrire la fabrique**
```python
# interface/__init__.py
"""Choisit l'implémentation d'overlay selon la plateforme."""
import sys
def classe_overlay_pour(plateforme=None):
"""Rend la CLASSE d'overlay (pas une instance : Cocoa exige `alloc().init()`)."""
plateforme = plateforme or sys.platform
if plateforme.startswith("darwin"):
from interface.cocoa.overlay import PulseWindow
return PulseWindow
raise NotImplementedError(f"Aucune interface pour : {plateforme}")
```
- [ ] **Step 5 : Lancer toute la suite**
Run: `.venv/bin/python -m pytest -q`
Expected: PASS — **91 tests** (+2 pour l'interface)
- [ ] **Step 6 : Commit**
```bash
git add interface tests/test_interface.py
git commit -m "Deplace l'overlay Cocoa derriere le contrat Overlay, sans le modifier"
```
---
## Task 5 : La capture audio, déjà portable
**Files:**
- Create: `capture.py`
- Modify: `controller.py:15`
- Delete: `engine.py`
- Test: `tests/test_liberation_flux.py`
- [ ] **Step 1 : Déplacer `Recorder` et `inject_text`**
```bash
git mv engine.py capture.py
```
Puis, dans `capture.py` :
- supprimer la classe `Transcriber` (elle vit désormais dans `moteur/mlx.py`) ;
- supprimer `SOUNDS`, `play_sound` et `modificateurs_enfonces` (ils vivent dans `systeme/macos.py`) ;
- remplacer l'import `from core import CONFIG, attendre, clean_transcript` par `from core import CONFIG, attendre` ;
- donner à `inject_text` un paramètre `systeme` :
```python
def inject_text(text, systeme):
"""Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active.
On attend d'abord que l'utilisateur ait relâché ses touches : s'il tient encore
la hotkey quand on envoie Cmd+V, le système reçoit une combinaison différente et
l'application cible ne colle rien — sans la moindre erreur de notre côté
(constaté le 2026-08-21).
"""
if not text:
return
pyperclip.copy(text)
libre = attendre(
lambda: not systeme.modificateurs_enfonces(),
CONFIG["attente_modificateurs_s"],
0.03,
)
if not libre:
print("[collage] modificateurs encore enfoncés — collage tenté quand même")
time.sleep(CONFIG["delai_presse_papier_s"])
with _kbd.pressed(Key.cmd):
_kbd.press("v")
_kbd.release("v")
print("[collage] ok")
```
- [ ] **Step 2 : Mettre à jour l'import des tests**
Dans `tests/test_liberation_flux.py`, remplacer :
```python
from engine import Recorder
```
par :
```python
from capture import Recorder
```
- [ ] **Step 3 : Lancer les tests de capture**
Run: `.venv/bin/python -m pytest tests/test_liberation_flux.py -q`
Expected: PASS — 6 tests
- [ ] **Step 4 : Commit**
```bash
git add capture.py tests/test_liberation_flux.py
git commit -m "Isole la capture audio, deja portable, dans capture.py"
```
---
## Task 6 : Le contrôleur reçoit ses collaborateurs
**Files:**
- Modify: `controller.py`
- Test: `tests/test_statut_bulle.py`
- [ ] **Step 1 : Écrire le test qui échoue**
Ajouter à `tests/test_statut_bulle.py` :
```python
def test_le_controleur_n_importe_aucun_module_de_plateforme():
"""Le noyau ne doit connaître que des contrats. Si cette assertion tombe, une
dépendance de plateforme s'est glissée dans le chemin portable."""
import controller
source = open(controller.__file__, encoding="utf-8").read()
for interdit in ("AppKit", "Foundation", "objc", "Quartz", "mlx_whisper",
"afplay", "from engine import"):
assert interdit not in source, f"{interdit} n'a rien à faire dans controller.py"
```
- [ ] **Step 2 : Lancer le test pour vérifier qu'il échoue**
Run: `.venv/bin/python -m pytest tests/test_statut_bulle.py -q -k importe`
Expected: FAIL — `from engine import n'a rien à faire dans controller.py`
- [ ] **Step 3 : Injecter les collaborateurs**
Dans `controller.py`, remplacer la ligne 15 :
```python
from engine import Recorder, Transcriber, inject_text, play_sound
```
par :
```python
from capture import Recorder, inject_text
```
Puis modifier la signature et le corps de `__init__` :
```python
def __init__(self, config, pulse_window, transcripteur, systeme,
on_state=None, run_on_main=None, lancer_tache=None):
self.config = config
self.pulse = pulse_window
self.transcriber = transcripteur
self.systeme = systeme
```
Le reste de `__init__` est inchangé, sauf la ligne qui construisait le transcripteur, à supprimer :
```python
self.transcriber = Transcriber(config["model"], config["language"]) # SUPPRIMER
```
Enfin, remplacer partout dans le fichier `play_sound(` par `self.systeme.jouer_son(`, et `inject_text(text)` par `inject_text(text, self.systeme)`.
- [ ] **Step 4 : Adapter les doublures des tests**
Dans `tests/test_statut_bulle.py`, ajouter une doublure de système et la passer à la fabrique locale `_ctrl` :
```python
class FauxSysteme:
def __init__(self): self.sons = []
def jouer_son(self, nom): self.sons.append(nom)
def modificateurs_enfonces(self): return False
def raccourci_defaut(self): return "<cmd>+<ctrl>+d"
def chemin_log(self): return "/tmp/zonza-test.log"
def dossier_donnees(self): return "/tmp/zonza-test"
def acquerir_verrou_instance(self, chemin): return object()
def _ctrl(recorder, taches=None):
pulse = FauxPulse()
faux_transcripteur = type("T", (), {
"transcrire": staticmethod(lambda a: "bonjour"),
"warmup": staticmethod(lambda: None),
})()
c = ZonzaController(CONFIG, pulse, faux_transcripteur, FauxSysteme(),
lancer_tache=taches)
c.recorder = recorder
return c, pulse
```
Le contrôleur appelle `transcrire`, non plus `transcribe` : renommer l'appel dans `_fermer_et_transcrire`.
- [ ] **Step 5 : Lancer toute la suite**
Run: `.venv/bin/python -m pytest -q`
Expected: PASS — **92 tests** (+1 pour la garde d'imports)
- [ ] **Step 6 : Commit**
```bash
git add controller.py tests/test_statut_bulle.py
git commit -m "Le controleur recoit transcripteur et systeme par injection"
```
---
## Task 7 : Câbler le lanceur et prouver l'absence de régression
**Files:**
- Modify: `app.py`
- Delete: `pulse_window.py` (déjà déplacé), `engine.py` (déjà déplacé)
- [ ] **Step 1 : Relever le cdhash AVANT toute reconstruction**
```bash
codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash='
```
Noter la valeur. Elle devra être **identique** à la fin : `app.py` ne change pas de nom, donc le stub ne change pas, donc l'autorisation d'Accessibilité survit.
- [ ] **Step 2 : Câbler les fabriques dans `app.py`**
Remplacer les imports (lignes 30-32) :
```python
from controller import ZonzaController
from core import CONFIG, validate_config
from interface import classe_overlay_pour
from moteur import choisir_transcripteur, detecter_moteur
from systeme import choisir_systeme
```
Dans `ZonzaApp.init`, remplacer la construction du contrôleur :
```python
self.systeme = choisir_systeme()
self.pulse = classe_overlay_pour().alloc().init()
transcripteur = choisir_transcripteur(
nom=detecter_moteur(),
modele=CONFIG["model"],
langue=CONFIG["language"],
amorce=CONFIG["initial_prompt"],
)
self.controller = ZonzaController(
CONFIG, self.pulse, transcripteur, self.systeme,
on_state=self._set_state, run_on_main=AppHelper.callAfter,
)
```
Dans `main()`, remplacer la prise du verrou :
```python
systeme = choisir_systeme()
import os
lock = systeme.acquerir_verrou_instance(
os.path.join(systeme.dossier_donnees(), "instance.lock")
)
if lock is None:
print("[garde] une autre instance de Zonza tourne deja — arret.")
return
main._lock = lock
```
Et dans `_start_hotkey`, lire le raccourci depuis le système :
```python
self._listener = keyboard.GlobalHotKeys(
{self.systeme.raccourci_defaut(): on_activate}
)
```
- [ ] **Step 3 : Lancer toute la suite**
Run: `.venv/bin/python -m pytest -q`
Expected: PASS — **92 tests**
- [ ] **Step 4 : Reconstruire et vérifier le cdhash**
```bash
pkill -f '/Applications/Zonza.app/Contents/MacOS/zonza'
./build_app.sh
codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash='
```
Expected: **la même valeur qu'à l'étape 1**. Si elle diffère, l'autorisation d'Accessibilité sera révoquée — s'arrêter et comprendre pourquoi avant de continuer.
- [ ] **Step 5 : Vérifier que Zonza fonctionne réellement**
```bash
open -a Zonza
sleep 10
grep 'accessibilité accordée' ~/Library/Logs/Zonza.log | tail -1
```
Expected: `[diag] accessibilité accordée : True`
Puis dicter une phrase contenant « Playwright » et « Gitea », et vérifier dans le journal :
- une ligne `[texte]` avec les deux termes correctement orthographiés ;
- une ligne `[collage] ok` juste après.
- [ ] **Step 6 : Commit**
```bash
git add app.py
git commit -m "Cable le lanceur sur les trois fabriques"
git push origin main
```
---
## Vérification finale de l'étape
- [ ] `.venv/bin/python -m pytest -q`**92 tests verts** (80 au depart, +12)
- [ ] `codesign -dvvv /Applications/Zonza.app | grep CDHash` → identique à avant l'étape
- [ ] `[diag] accessibilité accordée : True` dans le journal
- [ ] une dictée réelle produit `[texte]` puis `[collage] ok`
- [ ] `grep -rE 'AppKit|Quartz|mlx_whisper' core.py controller.py audio_level.py capture.py` → **aucun résultat**
- [ ] `git status --porcelain` → vide