Deux bundles portant l'identifiant cloud.mrtechlab.zonza etaient enregistres aupres de LaunchServices : /Applications/Zonza.app et un fantome laisse dans la Corbeille le 13 juin, dont le stub visait l'ancien chemin du depot — valide jusqu'a aujourd'hui. Les deux etaient donc lancables simultanement. Chaque instance chargeait son propre modele MLX-Whisper medium et captait la MEME hotkey globale : un seul appui declenchait deux transcriptions, la machine saturait. Le fantome est desenregistre, mais la classe de bug revient au moindre bundle egare. D'ou un verrou exclusif (flock non bloquant) pris au demarrage : la seconde instance renonce et sort. Le noyau libere le verrou meme apres un plantage, donc pas d'orphelin. Prouve avec le vrai binaire : 2e lancement direct -> "[garde] une autre instance tourne deja", 1 seul process ; et controle negatif, seule elle demarre bien. 27 tests passent (22 + 5 nouveaux). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
5.9 KiB
Python
157 lines
5.9 KiB
Python
"""Point d'entrée : application macOS de barre de menu (pyobjc natif, sans rumps).
|
|
|
|
Possède la boucle d'événements Cocoa. Crée le NSStatusItem (🎙️), enregistre la hotkey
|
|
globale, lance le warmup du modèle en fond, et instancie le contrôleur + la fenêtre de
|
|
pulsation.
|
|
|
|
Pourquoi pas rumps : sur macOS Tahoe (26), l'item de barre de menu créé par rumps
|
|
n'est jamais placé (fenêtre de hauteur 0) alors qu'un NSStatusItem natif créé par la
|
|
même app s'affiche normalement (isolé par tests successifs le 2026-06-13). L'API
|
|
native est de toute façon minuscule pour nos besoins : une icône, deux items de menu.
|
|
|
|
Zonza vit dans la barre de menu, jamais dans le Dock : c'est le LSUIElement de
|
|
l'Info.plist (posé par build_app.sh) qui s'en charge.
|
|
"""
|
|
import threading
|
|
|
|
from objc import super
|
|
|
|
from AppKit import (
|
|
NSApplication,
|
|
NSMenu,
|
|
NSMenuItem,
|
|
NSStatusBar,
|
|
NSVariableStatusItemLength,
|
|
)
|
|
from Foundation import NSObject, NSTimer
|
|
from PyObjCTools import AppHelper
|
|
from pynput import keyboard
|
|
|
|
from controller import ZonzaController
|
|
from core import CONFIG, acquire_single_instance_lock, validate_config
|
|
from pulse_window import PulseWindow
|
|
|
|
_STATE_TITLES = {
|
|
"idle": "🎙️",
|
|
"recording": "🔴",
|
|
"transcribing": "⏳",
|
|
}
|
|
|
|
|
|
class ZonzaApp(NSObject):
|
|
"""Délégué applicatif : status item + menu + hotkey + contrôleur."""
|
|
|
|
def init(self):
|
|
self = super().init()
|
|
if self is None:
|
|
return None
|
|
self._item = None
|
|
self._sounds_item = None
|
|
self.pulse = PulseWindow.alloc().init()
|
|
self.controller = ZonzaController(
|
|
CONFIG, self.pulse, on_state=self._set_state,
|
|
run_on_main=AppHelper.callAfter,
|
|
)
|
|
self._listener = None
|
|
return self
|
|
|
|
# --- barre de menu -------------------------------------------------
|
|
def setup(self):
|
|
bar = NSStatusBar.systemStatusBar()
|
|
self._item = bar.statusItemWithLength_(NSVariableStatusItemLength)
|
|
self._item.button().setTitle_(_STATE_TITLES["idle"])
|
|
|
|
menu = NSMenu.alloc().init()
|
|
self._sounds_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
|
|
"Sons", "toggleSounds:", ""
|
|
)
|
|
self._sounds_item.setTarget_(self)
|
|
self._sounds_item.setState_(1 if CONFIG["sounds"] else 0)
|
|
menu.addItem_(self._sounds_item)
|
|
menu.addItem_(NSMenuItem.separatorItem())
|
|
quit_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
|
|
"Quitter", "quitApp:", "q"
|
|
)
|
|
quit_item.setTarget_(self)
|
|
menu.addItem_(quit_item)
|
|
self._item.setMenu_(menu)
|
|
|
|
self._start_hotkey()
|
|
self._warmup_async()
|
|
# auto-diagnostic : trace le placement réel de l'icône (h=33 → visible dans la
|
|
# barre ; h=0 → refusée par le gestionnaire de barre de menu, cf launcher/stub.c)
|
|
NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
|
5.0, self, "logPlacement:", None, False
|
|
)
|
|
|
|
def logPlacement_(self, timer):
|
|
win = self._item.button().window() if self._item else None
|
|
print(f"[diag] icône barre de menu : {win.frame() if win else 'absente'}")
|
|
|
|
# --- état (appelé depuis des threads de fond) ----------------------
|
|
def _set_state(self, state):
|
|
# toucher AppKit doit se faire sur le thread principal → callAfter.
|
|
AppHelper.callAfter(self._apply_title, state)
|
|
|
|
def _apply_title(self, state):
|
|
if self._item is not None:
|
|
self._item.button().setTitle_(_STATE_TITLES.get(state, "🎙️"))
|
|
|
|
# --- actions du menu ------------------------------------------------
|
|
def toggleSounds_(self, sender):
|
|
CONFIG["sounds"] = not CONFIG["sounds"]
|
|
sender.setState_(1 if CONFIG["sounds"] else 0)
|
|
|
|
def quitApp_(self, sender):
|
|
if self._listener is not None:
|
|
self._listener.stop()
|
|
NSApplication.sharedApplication().terminate_(None)
|
|
|
|
# --- hotkey globale ---------------------------------------------------
|
|
def _start_hotkey(self):
|
|
# pynput exécute ce callback sur SON thread d'écoute, pas le thread principal.
|
|
# toggle() touche NSWindow/NSTimer (Cocoa) → on marshale sur le thread principal.
|
|
def on_activate():
|
|
AppHelper.callAfter(self.controller.toggle)
|
|
self._listener = keyboard.GlobalHotKeys({CONFIG["hotkey"]: on_activate})
|
|
self._listener.start()
|
|
|
|
# --- warmup modèle ----------------------------------------------------
|
|
def _warmup_async(self):
|
|
def run():
|
|
try:
|
|
self.controller.transcriber.warmup()
|
|
except Exception as e:
|
|
print(f"[erreur] warmup modèle : {e}")
|
|
threading.Thread(target=run, daemon=True).start()
|
|
|
|
|
|
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 —
|
|
# bien plus fiable qu'un ajout manuel via « + » dans les Réglages.
|
|
from ApplicationServices import (
|
|
AXIsProcessTrustedWithOptions,
|
|
kAXTrustedCheckOptionPrompt,
|
|
)
|
|
trusted = AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True})
|
|
print(f"[diag] accessibilité accordée : {bool(trusted)}")
|
|
app = NSApplication.sharedApplication()
|
|
delegate = ZonzaApp.alloc().init()
|
|
delegate.setup()
|
|
# référence gardée pour toute la vie du processus (sinon GC → icône disparaît)
|
|
main._delegate = delegate
|
|
AppHelper.runEventLoop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|