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:
Ralph Mayola 2026-06-13 00:54:44 +02:00
parent 2b0d81796b
commit 2236f2d11f
4 changed files with 176 additions and 49 deletions

105
app.py
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, Possède la boucle d'événements Cocoa. Crée le NSStatusItem (🎙️), enregistre la hotkey
lance le warmup du modèle en fond, et instancie le contrôleur + la fenêtre de pulsation. 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 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. l'Info.plist (posé par build_app.sh) qui s'en charge.
""" """
import threading 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 PyObjCTools import AppHelper
from pynput import keyboard from pynput import keyboard
@ -23,38 +38,76 @@ _STATE_TITLES = {
} }
class ZonzaApp(rumps.App): class ZonzaApp(NSObject):
def __init__(self): """Délégué applicatif : status item + menu + hotkey + contrôleur."""
super().__init__("🎙️", quit_button=None)
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.pulse = PulseWindow.alloc().init()
self.controller = ZonzaController( self.controller = ZonzaController(
CONFIG, self.pulse, on_state=self._set_state, CONFIG, self.pulse, on_state=self._set_state,
run_on_main=AppHelper.callAfter, run_on_main=AppHelper.callAfter,
) )
self.menu = [ self._listener = None
rumps.MenuItem("Sons", callback=self._toggle_sounds), return self
None,
rumps.MenuItem("Quitter", callback=self._quit), # --- barre de menu -------------------------------------------------
] def setup(self):
self.menu["Sons"].state = CONFIG["sounds"] 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._start_hotkey()
self._warmup_async() 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): def _set_state(self, state):
# appelé depuis des threads de fond (transcription) ; toucher le titre du menu # toucher AppKit doit se faire sur le thread principal → callAfter.
# (AppKit) doit se faire sur le thread principal → on marshale via callAfter.
AppHelper.callAfter(self._apply_title, state) AppHelper.callAfter(self._apply_title, state)
def _apply_title(self, 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"] CONFIG["sounds"] = not CONFIG["sounds"]
sender.state = CONFIG["sounds"] sender.setState_(1 if CONFIG["sounds"] else 0)
def _quit(self, _): def quitApp_(self, sender):
rumps.quit_application() if self._listener is not None:
self._listener.stop()
NSApplication.sharedApplication().terminate_(None)
# --- hotkey globale ---------------------------------------------------
def _start_hotkey(self): def _start_hotkey(self):
# pynput exécute ce callback sur SON thread d'écoute, pas le thread principal. # 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. # 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 = keyboard.GlobalHotKeys({CONFIG["hotkey"]: on_activate})
self._listener.start() self._listener.start()
# --- warmup modèle ----------------------------------------------------
def _warmup_async(self): def _warmup_async(self):
def run(): def run():
try: try:
self.controller.transcriber.warmup() self.controller.transcriber.warmup()
except Exception as e: except Exception as e:
print(f"[erreur] warmup modèle : {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() threading.Thread(target=run, daemon=True).start()
def main(): def main():
validate_config(CONFIG) validate_config(CONFIG)
# PAS de setActivationPolicy_ ici : sur macOS Tahoe, forcer la politique avant app = NSApplication.sharedApplication()
# run() empêche rumps de créer l'icône de barre de menu. Le LSUIElement de delegate = ZonzaApp.alloc().init()
# l'Info.plist suffit à masquer l'icône Dock pour l'app installée. delegate.setup()
ZonzaApp().run() # 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__": if __name__ == "__main__":

View File

@ -1,17 +1,19 @@
#!/bin/bash #!/bin/bash
# Construit Zonza.app — un LANCEUR léger (pas un bundle autonome). # Construit Zonza.app — un stub compilé qui embarque libpython dans le processus.
# #
# Pourquoi un lanceur plutôt que PyInstaller/py2app : empaqueter MLX + multiprocessing + # Pourquoi un stub C (et pas un script bash qui exec python) : sur macOS Tahoe (26),
# hotkey globale sans compte développeur Apple se bat en permanence contre macOS (bombe à # le gestionnaire de barre de menu refuse de placer le NSStatusItem d'un processus
# fork, permissions TCC réinitialisées à chaque rebuild, shaders Metal non bundlés). Le # dont l'exécutable principal n'appartient pas au bundle lancé (le python framework
# lanceur exécute le code via le venv du dépôt, où TOUT fonctionne de façon stable. # se ré-exécute → identité dépareillée → icône invisible, fenêtre de hauteur 0).
# Le stub dlopen libpython et exécute app.py DANS le processus du bundle : l'identité
# est cohérente et l'icône s'affiche. (Validé par tests le 2026-06-13.)
#
# Pourquoi pas PyInstaller/py2app : empaqueter MLX (shaders Metal) échoue ; le code
# tourne donc depuis le dépôt + son venv (ne pas les supprimer).
# #
# RÈGLE DE PROPRETÉ : ce script est l'UNIQUE chemin de build. Il construit DIRECTEMENT # RÈGLE DE PROPRETÉ : ce script est l'UNIQUE chemin de build. Il construit DIRECTEMENT
# dans /Applications — jamais dans /tmp ni dans le dépôt. C'est ce qui garantit qu'une # dans /Applications — jamais dans /tmp ni dans le dépôt (l'historique de builds
# seule entrée existe dans LaunchServices (l'historique de builds éparpillés est ce qui # éparpillés est ce qui avait créé des icônes en double avec l'ancienne app Dictée).
# avait créé des icônes en double avec l'ancienne app Dictée).
#
# Limite : non distribuable seul (l'utilisateur doit cloner le dépôt + créer le venv).
# #
# Usage : ./build_app.sh → installe directement /Applications/Zonza.app # Usage : ./build_app.sh → installe directement /Applications/Zonza.app
set -euo pipefail set -euo pipefail
@ -22,20 +24,25 @@ PYBIN="$HERE/.venv/bin/python3"
[ -x "$PYBIN" ] || { echo "Erreur : venv introuvable ($PYBIN). Lance : python3 -m venv .venv && .venv/bin/pip install -r requirements.txt"; exit 1; } [ -x "$PYBIN" ] || { echo "Erreur : venv introuvable ($PYBIN). Lance : python3 -m venv .venv && .venv/bin/pip install -r requirements.txt"; exit 1; }
# chemins python injectés dans le stub
PYDYLIB="$("$PYBIN" -c "import sys; print(sys.base_prefix)")/../../Python"
PYDYLIB="$(cd "$(dirname "$PYDYLIB")" && pwd)/Python"
SITE="$("$PYBIN" -c "import sysconfig; print(sysconfig.get_paths()['purelib'])")"
[ -f "$PYDYLIB" ] || { echo "Erreur : dylib python introuvable ($PYDYLIB)"; exit 1; }
# icône (générée si absente) # icône (générée si absente)
[ -f "$HERE/assets/icon.icns" ] || { "$PYBIN" "$HERE/assets/make_icon.py" && "$HERE/assets/make_icns.sh"; } [ -f "$HERE/assets/icon.icns" ] || { "$PYBIN" "$HERE/assets/make_icon.py" && "$HERE/assets/make_icns.sh"; }
echo "→ construction du lanceur $APP" echo "→ construction de $APP"
rm -rf "$APP" rm -rf "$APP"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
# exécutable : un script qui lance l'app via le python du venv (PWD = dépôt) # stub compilé : libpython embarqué, exécutable = celui du bundle
cat > "$CONTENTS/MacOS/zonza" <<LAUNCHER clang -O2 \
#!/bin/bash -DPYTHON_DYLIB="\"$PYDYLIB\"" \
cd "$HERE" -DAPP_PY="\"$HERE/app.py\"" \
exec "$PYBIN" "$HERE/app.py" >> "\$HOME/Library/Logs/Zonza.log" 2>&1 -DVENV_SITE="\"$SITE\"" \
LAUNCHER "$HERE/launcher/stub.c" -o "$CONTENTS/MacOS/zonza"
chmod +x "$CONTENTS/MacOS/zonza"
# icône # icône
cp "$HERE/assets/icon.icns" "$CONTENTS/Resources/icon.icns" cp "$HERE/assets/icon.icns" "$CONTENTS/Resources/icon.icns"
@ -49,8 +56,8 @@ cat > "$CONTENTS/Info.plist" <<'PLIST'
<key>CFBundleName</key><string>Zonza</string> <key>CFBundleName</key><string>Zonza</string>
<key>CFBundleDisplayName</key><string>Zonza</string> <key>CFBundleDisplayName</key><string>Zonza</string>
<key>CFBundleIdentifier</key><string>cloud.mrtechlab.zonza</string> <key>CFBundleIdentifier</key><string>cloud.mrtechlab.zonza</string>
<key>CFBundleVersion</key><string>1.0</string> <key>CFBundleVersion</key><string>1.1</string>
<key>CFBundleShortVersionString</key><string>1.0</string> <key>CFBundleShortVersionString</key><string>1.1</string>
<key>CFBundlePackageType</key><string>APPL</string> <key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleExecutable</key><string>zonza</string> <key>CFBundleExecutable</key><string>zonza</string>
<key>CFBundleIconFile</key><string>icon</string> <key>CFBundleIconFile</key><string>icon</string>
@ -61,11 +68,15 @@ cat > "$CONTENTS/Info.plist" <<'PLIST'
</plist> </plist>
PLIST PLIST
# enregistrement propre dans LaunchServices (remplace toute entrée précédente du même chemin) # signature ad-hoc avec identifiant stable : les permissions TCC (Accessibilité,
# Surveillance de l'entrée, Micro) restent acquises d'un rebuild à l'autre.
codesign --force --sign - --identifier "cloud.mrtechlab.zonza" "$APP"
# enregistrement propre dans LaunchServices
LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister
"$LSREGISTER" -f "$APP" 2>/dev/null || true "$LSREGISTER" -f "$APP" 2>/dev/null || true
touch "$APP" touch "$APP"
echo "$APP installé." echo "$APP installé."
echo " Lance Zonza depuis Spotlight, accorde Surveillance de l'entrée + Accessibilité une" echo " Au premier lancement, accorde à « Zonza » : Accessibilité + Surveillance de"
echo " fois (à 'python3' ou 'Zonza' selon ce que macOS propose), c'est définitif." echo " l'entrée (Réglages > Confidentialité). Logs : ~/Library/Logs/Zonza.log"

62
launcher/stub.c Normal file
View File

@ -0,0 +1,62 @@
/* Stub de lancement Zonza — embarque libpython DANS le processus du bundle.
*
* Pourquoi pas un script bash qui exec python ? Sur macOS Tahoe (26), le
* gestionnaire de barre de menu refuse de placer le NSStatusItem d'un processus
* dont l'exécutable principal n'appartient pas au bundle lancé par LaunchServices
* (le python framework se -exécute dans son Python.app interne identité
* dépareillée icône créée mais jamais affichée, fenêtre de hauteur 0).
* Vérifié par tests A/B/C/D le 2026-06-13 : seul un exécutable du bundle qui
* garde son image (dlopen de libpython, pas d'exec) obtient un emplacement.
*
* Compilé par build_app.sh avec les chemins injectés en -D :
* PYTHON_DYLIB dylib du framework Python (Homebrew)
* APP_PY chemin absolu de app.py dans le dépôt
* VENV_SITE site-packages du venv du dépôt
*/
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
typedef int (*py_bytes_main_t)(int, char **);
int main(int argc, char **argv) {
/* logs de l'app → ~/Library/Logs/Zonza.log (le bundle n'a pas de terminal) */
const char *home = getenv("HOME");
if (home) {
char logpath[1024];
snprintf(logpath, sizeof logpath, "%s/Library/Logs/Zonza.log", home);
freopen(logpath, "a", stdout);
freopen(logpath, "a", stderr);
setvbuf(stdout, NULL, _IOLBF, 0);
setvbuf(stderr, NULL, _IOLBF, 0);
}
/* le venv n'est pas détecté (pas de pyvenv.cfg à côté de notre binaire) :
* on ajoute ses site-packages via PYTHONPATH avant l'init de python. */
setenv("PYTHONPATH", VENV_SITE, 1);
/* sans tty, python bufferise stdout → les print n'atteindraient le log qu'à la
* sortie du processus. */
setenv("PYTHONUNBUFFERED", "1", 1);
void *lib = dlopen(PYTHON_DYLIB, RTLD_NOW | RTLD_GLOBAL);
if (!lib) {
fprintf(stderr, "zonza: dlopen libpython a échoué : %s\n", dlerror());
return 1;
}
py_bytes_main_t py_main = (py_bytes_main_t)dlsym(lib, "Py_BytesMain");
if (!py_main) {
fprintf(stderr, "zonza: Py_BytesMain introuvable : %s\n", dlerror());
return 1;
}
/* CRITIQUE anti bombe-à-fork : multiprocessing (MLX/numpy) relance
* sys.executable c'est-à-dire CE stub avec ses propres arguments
* (-c --multiprocessing-fork). Si on forçait app.py inconditionnellement,
* chaque worker relancerait l'app entière (vérifié : 9 instances).
* Donc : des arguments présents on les transmet tels quels à python ;
* aucun argument (lancement normal par l'utilisateur) on lance app.py. */
if (argc > 1) {
return py_main(argc, argv);
}
char *py_argv[] = {argv[0], APP_PY, NULL};
return py_main(2, py_argv);
}

View File

@ -3,6 +3,5 @@ sounddevice>=0.5.5
numpy>=2.4 numpy>=2.4
pynput>=1.8.2 pynput>=1.8.2
pyperclip>=1.11 pyperclip>=1.11
rumps>=0.4.0
pillow>=11 pillow>=11
pytest>=8.0 pytest>=8.0