From 2236f2d11f4ba97d87e101c1b2e78c91c3687761 Mon Sep 17 00:00:00 2001 From: Ralph Mayola Date: Sat, 13 Jun 2026 00:54:44 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20ic=C3=B4ne=20barre=20de=20menu=20sur=20T?= =?UTF-8?q?ahoe=20=E2=80=94=20stub=20libpython=20+=20abandon=20de=20rumps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app.py | 105 ++++++++++++++++++++++++++++++++++++----------- build_app.sh | 57 ++++++++++++++----------- launcher/stub.c | 62 ++++++++++++++++++++++++++++ requirements.txt | 1 - 4 files changed, 176 insertions(+), 49 deletions(-) create mode 100644 launcher/stub.c diff --git a/app.py b/app.py index a6d7ad9..90e96bd 100644 --- a/app.py +++ b/app.py @@ -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__": diff --git a/build_app.sh b/build_app.sh index 027b728..a49a829 100755 --- a/build_app.sh +++ b/build_app.sh @@ -1,17 +1,19 @@ #!/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 + -# hotkey globale sans compte développeur Apple se bat en permanence contre macOS (bombe à -# fork, permissions TCC réinitialisées à chaque rebuild, shaders Metal non bundlés). Le -# lanceur exécute le code via le venv du dépôt, où TOUT fonctionne de façon stable. +# Pourquoi un stub C (et 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é (le python framework +# 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 -# dans /Applications — jamais dans /tmp ni dans le dépôt. C'est ce qui garantit qu'une -# seule entrée existe dans LaunchServices (l'historique de builds éparpillés est ce qui -# 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). +# dans /Applications — jamais dans /tmp ni dans le dépôt (l'historique de builds +# éparpillés est ce qui avait créé des icônes en double avec l'ancienne app Dictée). # # Usage : ./build_app.sh → installe directement /Applications/Zonza.app 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; } +# 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) [ -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" mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" -# exécutable : un script qui lance l'app via le python du venv (PWD = dépôt) -cat > "$CONTENTS/MacOS/zonza" <> "\$HOME/Library/Logs/Zonza.log" 2>&1 -LAUNCHER -chmod +x "$CONTENTS/MacOS/zonza" +# stub compilé : libpython embarqué, exécutable = celui du bundle +clang -O2 \ + -DPYTHON_DYLIB="\"$PYDYLIB\"" \ + -DAPP_PY="\"$HERE/app.py\"" \ + -DVENV_SITE="\"$SITE\"" \ + "$HERE/launcher/stub.c" -o "$CONTENTS/MacOS/zonza" # icône cp "$HERE/assets/icon.icns" "$CONTENTS/Resources/icon.icns" @@ -49,8 +56,8 @@ cat > "$CONTENTS/Info.plist" <<'PLIST' CFBundleNameZonza CFBundleDisplayNameZonza CFBundleIdentifiercloud.mrtechlab.zonza - CFBundleVersion1.0 - CFBundleShortVersionString1.0 + CFBundleVersion1.1 + CFBundleShortVersionString1.1 CFBundlePackageTypeAPPL CFBundleExecutablezonza CFBundleIconFileicon @@ -61,11 +68,15 @@ cat > "$CONTENTS/Info.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" -f "$APP" 2>/dev/null || true touch "$APP" echo "✓ $APP installé." -echo " Lance Zonza depuis Spotlight, accorde Surveillance de l'entrée + Accessibilité une" -echo " fois (à 'python3' ou 'Zonza' selon ce que macOS propose), c'est définitif." +echo " Au premier lancement, accorde à « Zonza » : Accessibilité + Surveillance de" +echo " l'entrée (Réglages > Confidentialité). Logs : ~/Library/Logs/Zonza.log" diff --git a/launcher/stub.c b/launcher/stub.c new file mode 100644 index 0000000..5eaf821 --- /dev/null +++ b/launcher/stub.c @@ -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 ré-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 +#include +#include + +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); +} diff --git a/requirements.txt b/requirements.txt index e8f9c81..800b970 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,5 @@ sounddevice>=0.5.5 numpy>=2.4 pynput>=1.8.2 pyperclip>=1.11 -rumps>=0.4.0 pillow>=11 pytest>=8.0