AXIsProcessTrustedWithOptions(prompt) : macOS affiche sa popup officielle et crée l'entrée TCC correctement (l'ajout manuel via « + » ne prenait pas). Diag de l'état de confiance dans le log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
150 lines
5.5 KiB
Python
150 lines
5.5 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, 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():
|
|
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()
|