# 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() == "++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 "++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 "++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