fix: icône barre de menu sur Tahoe — stub libpython + abandon de rumps

Sur macOS Tahoe (26.5), le NSStatusItem n'est jamais placé (fenêtre h=0) si :
1. l'exécutable du processus n'appartient pas au bundle lancé (le python
   framework se ré-exécute → identité dépareillée) → stub C qui dlopen
   libpython et exécute app.py DANS le processus du bundle ;
2. l'item est créé par rumps (cause interne non isolée, l'API native
   fonctionne) → app.py réécrit en pyobjc pur (NSStatusItem + NSMenu).

Le stub transmet argv à python quand il y en a (workers multiprocessing
de MLX) — sinon bombe à fork (9 instances observées). PYTHONUNBUFFERED
pour que les print atteignent ~/Library/Logs/Zonza.log. Auto-diagnostic
du placement de l'icône au démarrage. rumps retiré des dépendances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 00:54:44 +02:00
co-authored by Claude Fable 5
parent 2b0d81796b
commit 2236f2d11f
4 changed files with 176 additions and 49 deletions
+80 -25
View File
@@ -1,14 +1,29 @@
"""Point d'entrée : application macOS de barre de menu (rumps).
"""Point d'entrée : application macOS de barre de menu (pyobjc natif, sans rumps).
Possède la boucle d'événements Cocoa. Enregistre la hotkey globale, gère l'état du menu,
lance le warmup du modèle en fond, et instancie le contrôleur + la fenêtre de pulsation.
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
import rumps
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
@@ -23,38 +38,76 @@ _STATE_TITLES = {
}
class ZonzaApp(rumps.App):
def __init__(self):
super().__init__("🎙️", quit_button=None)
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.menu = [
rumps.MenuItem("Sons", callback=self._toggle_sounds),
None,
rumps.MenuItem("Quitter", callback=self._quit),
]
self.menu["Sons"].state = CONFIG["sounds"]
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):
# appelé depuis des threads de fond (transcription) ; toucher le titre du menu
# (AppKit) doit se faire sur le thread principal → on marshale via callAfter.
# toucher AppKit doit se faire sur le thread principal → callAfter.
AppHelper.callAfter(self._apply_title, state)
def _apply_title(self, state):
self.title = _STATE_TITLES.get(state, "🎙️")
if self._item is not None:
self._item.button().setTitle_(_STATE_TITLES.get(state, "🎙️"))
def _toggle_sounds(self, sender):
# --- actions du menu ------------------------------------------------
def toggleSounds_(self, sender):
CONFIG["sounds"] = not CONFIG["sounds"]
sender.state = CONFIG["sounds"]
sender.setState_(1 if CONFIG["sounds"] else 0)
def _quit(self, _):
rumps.quit_application()
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.
@@ -63,22 +116,24 @@ class ZonzaApp(rumps.App):
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}")
rumps.notification("Zonza", "Modèle", f"Échec du chargement : {e}")
threading.Thread(target=run, daemon=True).start()
def main():
validate_config(CONFIG)
# PAS de setActivationPolicy_ ici : sur macOS Tahoe, forcer la politique avant
# run() empêche rumps de créer l'icône de barre de menu. Le LSUIElement de
# l'Info.plist suffit à masquer l'icône Dock pour l'app installée.
ZonzaApp().run()
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__":