diff --git a/app.py b/app.py index cdaa832..85adfb1 100644 --- a/app.py +++ b/app.py @@ -28,7 +28,7 @@ from PyObjCTools import AppHelper from pynput import keyboard from controller import ZonzaController -from core import CONFIG, validate_config +from core import CONFIG, acquire_single_instance_lock, validate_config from pulse_window import PulseWindow _STATE_TITLES = { @@ -127,6 +127,13 @@ class ZonzaApp(NSObject): def main(): + # une seule instance a la fois : sinon deux modeles en RAM et la meme + # hotkey captee deux fois (cf acquire_single_instance_lock). + lock = acquire_single_instance_lock() + 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 validate_config(CONFIG) # si l'Accessibilité n'est pas accordée, macOS affiche sa popup officielle # (« Zonza souhaite contrôler cet ordinateur ») et ajoute l'app à la liste — diff --git a/core.py b/core.py index 95bef4a..66e28bf 100644 --- a/core.py +++ b/core.py @@ -3,6 +3,8 @@ Tout ce qui est ici est testable sans micro, sans clavier, sans modèle. """ +import fcntl +import os import re CONFIG = { @@ -44,3 +46,32 @@ def is_long_enough(num_samples, sample_rate, min_duration_s): def clean_transcript(text): """Nettoie le texte de Whisper : trim + espaces/retours multiples écrasés.""" return re.sub(r"\s+", " ", text).strip() + + +LOCK_PATH = os.path.expanduser("~/Library/Application Support/Zonza/instance.lock") + + +def acquire_single_instance_lock(path=LOCK_PATH): + """Prend un verrou exclusif non bloquant ; renvoie le fichier, ou None si une + autre instance le detient deja. + + Pourquoi : deux bundles Zonza de meme identifiant (dont un fantome reste dans + la Corbeille mais toujours enregistre aupres de LaunchServices) pouvaient + tourner en meme temps. Chacun chargeait son propre modele MLX-Whisper et + captait la MEME hotkey globale : un appui, deux transcriptions, machine + saturee. Purger le fantome corrige l'incident ; ce verrou corrige la classe. + + Le handle doit rester vivant tant que l'app tourne : le verrou tombe a la + fermeture du fichier, et le noyau le libere si le processus meurt brutalement + (donc pas de verrou orphelin apres un plantage). + """ + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + handle = open(path, "a") + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + handle.close() + return None + return handle diff --git a/tests/test_single_instance.py b/tests/test_single_instance.py new file mode 100644 index 0000000..a86431f --- /dev/null +++ b/tests/test_single_instance.py @@ -0,0 +1,51 @@ +"""Garde d'instance unique. + +Contexte (bug du 2026-08-21) : deux bundles Zonza portant le MÊME identifiant +(`/Applications/Zonza.app` et un fantôme resté dans la Corbeille, encore +enregistré auprès de LaunchServices) pouvaient tourner simultanément. Chaque +instance chargeait son propre modèle MLX-Whisper `medium` et captait la même +hotkey globale : un seul appui déclenchait deux transcriptions, la machine +saturait. + +Purger le fantôme corrige l'incident du jour ; cette garde corrige la CLASSE de +bug — quel que soit le nombre de bundles présents, la 2e instance renonce. +""" +import os +import tempfile + +from core import acquire_single_instance_lock + + +def _tmp_lock(): + return os.path.join(tempfile.mkdtemp(), "sous-dossier", "zonza.lock") + + +def test_premiere_instance_obtient_le_verrou(): + handle = acquire_single_instance_lock(_tmp_lock()) + assert handle is not None + + +def test_deuxieme_instance_est_refusee(): + path = _tmp_lock() + premier = acquire_single_instance_lock(path) + assert premier is not None + assert acquire_single_instance_lock(path) is None + + +def test_verrou_repris_apres_liberation(): + path = _tmp_lock() + premier = acquire_single_instance_lock(path) + premier.close() + assert acquire_single_instance_lock(path) is not None + + +def test_cree_le_dossier_parent_manquant(): + path = _tmp_lock() + assert not os.path.isdir(os.path.dirname(path)) + acquire_single_instance_lock(path) + assert os.path.isdir(os.path.dirname(path)) + + +def test_deux_chemins_differents_ne_se_genent_pas(): + assert acquire_single_instance_lock(_tmp_lock()) is not None + assert acquire_single_instance_lock(_tmp_lock()) is not None