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:
parent
2b0d81796b
commit
2236f2d11f
105
app.py
105
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,
|
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__":
|
||||||
|
|||||||
57
build_app.sh
57
build_app.sh
@ -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
62
launcher/stub.c
Normal 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 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 <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);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user