flutter-macos-tcc-responsible-process-camera-denial

Par divinevideo · divine-mobile

Corrige l'aperçu noir de la caméra/du microphone dans les applications Flutter macOS lancées via `flutter run` depuis un émulateur de terminal (tmux, iTerm, Terminal.app, terminal intégré VS Code, Cursor, etc.). À utiliser quand : (1) le plugin caméra signale une initialisation réussie mais l'aperçu est noir sans aucune frame, (2) les logs affichent « initialization completed via timeout » ou un fallback similaire de timeout sur la première frame, (3) `AVCaptureSession.startRunning()` retourne sans erreur mais aucune frame n'arrive, (4) le fichier `Info.plist` de l'application contient `NSCameraUsageDescription` et les entitlements (`com.apple.security.device.camera`, `audio-input`) sont correctement définis, (5) aucune boîte de dialogue de permission n'apparaît jamais, (6) même comportement pour le microphone. La cause racine est que macOS TCC attribue les demandes d'accès à la caméra/au micro au *processus responsable* (le terminal parent) plutôt qu'à l'application Flutter elle-même. TCC refuse d'afficher la demande car le terminal est en hardened runtime sans entitlement caméra, et refuse silencieusement. Il ne s'agit pas d'un bug dans le plugin caméra — le problème se reproduirait avec N'IMPORTE QUEL code caméra.

npx skills add https://github.com/divinevideo/divine-mobile --skill flutter-macos-tcc-responsible-process-camera-denial

Refus de caméra Flutter macOS TCC Processus responsable

Problème

Une application Flutter macOS qui devrait avoir accès à la caméra/microphone affiche un aperçu noir (ou retourne zéro images) même si :

  • NSCameraUsageDescription et NSMicrophoneUsageDescription sont présents dans macos/Runner/Info.plist.
  • com.apple.security.device.camera et com.apple.security.device.audio-input sont à true dans DebugProfile.entitlements et Release.entitlements.
  • Le plugin caméra natif signale isInitialized: true et retourne un ID de texture.
  • Aucune boîte de dialogue de permission n'apparaît jamais.
  • Aucune erreur n'est enregistrée par le plugin caméra ou AVFoundation.

Le symptôme est généralement un « fallback timeout » qui se déclenche dans le plugin (par exemple DivineCamera macOS: Initialization completed via timeout, ou similaire avec des gardes init de 2 secondes) car le plugin attend un premier buffer d'échantillon qui n'arrive jamais. Les métadonnées caméra du plugin montrent iso: 0.0, exposureDuration: 0.0, aperture: 0.0 — l'appareil a été énuméré mais n'a jamais commencé à fournir d'échantillons.

Contexte / Conditions déclenchantes

Tout ce qui suit est vrai :

  1. L'application a été lancée via flutter run -d macos (ou via flutter run depuis un IDE qui enveloppe flutter dans un terminal — VS Code, Cursor, Android Studio, cmux).
  2. L'émulateur de terminal est une application hardened-runtime sans entitlements caméra/micro dans sa propre signature de code.
  3. La version macOS est 10.15+ (TCC enforcement resserri ; affecte Big Sur et versions ultérieures, s'intensifie sur Sequoia/Tahoe).
  4. Aucun appel AVCaptureDevice.requestAccess(for: .video) explicite dans le plugin, OU l'appel est attribué au mauvais processus.

Spécifiquement, ce N'est PAS :

  • flutter-macos-permission-handler-camera-failure (c'est à propos du plugin permission_handler qui échoue silencieusement à faire le pont vers TCC).
  • flutter-macos-duplicate-camera-plugin (c'est une erreur de build).
  • Une entrée Info.plist manquante (la description d'utilisation EST présente).
  • Un entitlement manquant (l'entitlement EST présent).

Cause racine

macOS TCC attribue les demandes de permission au processus responsable, pas au processus d'accès. Quand flutter run lance le .app comme enfant du terminal, TCC parcourt l'arborescence des processus et choisit le terminal comme la « partie responsable » pour tout accès à une ressource protégée. C'est le même mécanisme qui provoque les invites « Terminal wants access to ... » au lieu d'invites pour les scripts individuels.

Pour la caméra/microphone spécifiquement, TCC impose que le processus responsable ait l'entitlement correspondant. Si cmux / iTerm / VS Code / etc. est le processus responsable et qu'il est hardened-runtime sans com.apple.security.device.camera, TCC enregistre :

tccd: Prompting policy for hardened runtime;
  service: kTCCServiceCamera requires entitlement com.apple.security.device.camera
  but it is missing for responsible={identifier=<terminal>, ...}
  accessing={identifier=<YourApp>, binary_path=.../YourApp.app/Contents/MacOS/YourApp}
tccd: Policy disallows prompt for Sub:{<terminal>}; access to kTCCServiceCamera denied

Critique : TCC n'affichera même pas une invité à l'utilisateur. Il refuse silencieusement. Du point de vue de l'application, AVCaptureSession.startRunning() retourne sans erreur mais zéro sample buffer ne sont jamais produits, donc tout timeout de premier cadre dans le plugin se déclenche finalement.

Diagnostic

Exécutez ceci pendant (ou juste après) la reproduction de l'échec :

/usr/bin/log show --last 10m --predicate 'subsystem == "com.apple.TCC"' --info 2>&1 \
  | grep -iE "camera|microphone|<YourAppName>|Runner" \
  | tail -40

Cherchez la phrase :

Policy disallows prompt for Sub:{<terminal.bundle.id>}...;
access to kTCCServiceCamera denied

et la AttributionChain montrant responsible={identifier=<terminal>} et accessing={identifier=<YourApp>}. La présence de ces lignes confirme le diagnostic.

Lire directement la base de données TCC (~/Library/Application Support/com.apple.TCC/TCC.db) échouera avec « authorization denied » sauf si Terminal a Accès disque complet — l'approche log show est le moyen fiable.

Solution

Correctif immédiat (test)

Lancez le bundle .app via Launch Services au lieu de flutter run. Launch Services rend l'app son propre processus responsable, de sorte que TCC demandera à l'utilisateur et attribuera la demande à l'app elle-même (qui a l'entitlement).

# Build une fois via flutter
cd <project>/mobile
flutter build macos --debug

# Puis lancez via `open` — PAS via `flutter run`
open build/macos/Build/Products/Debug/<YourApp>.app

open route via launchd/LaunchServices, donc le processus responsable devient <YourApp> plutôt que le terminal. Au premier lancement d'un état où TCC n'a pas d'entrée pour l'app, macOS affichera l'invite de permission standard.

Si TCC a déjà mis en cache l'état refusé

Si vous aviez précédemment lancé via flutter run, TCC peut avoir mis en cache une entrée refusée attribuée au terminal. Réinitialisez-la :

# Réduisez la portée : réinitialisez seulement les permissions de cette app
tccutil reset Camera <your.app.bundle.id>
tccutil reset Microphone <your.app.bundle.id>

# Ou réinitialisation générale (re-demande à CHAQUE app qui utilise caméra/micro) :
tccutil reset Camera
tccutil reset Microphone

Puis relancez via open.

Correctif long terme (applications distribuées)

Ce problème n'affecte que flutter run depuis un terminal. Les bundles .app de production distribués aux utilisateurs finaux (via DMG, Mac App Store, notarized download) lancent via LaunchServices et ne rencontrent pas cela. Aucun correctif de code n'est nécessaire pour les builds de release.

Pour une meilleure expérience développeur, les plugins devraient appeler AVCaptureDevice.requestAccess(for: .video) (et .audio) explicitement avant de démarrer la session de capture. Cela ne change pas le résultat TCC de flutter run (TCC attribue toujours au terminal), mais fournit un chemin d'erreur clair granted=false au lieu d'un timeout de cadre silencieux.

AVCaptureDevice.requestAccess(for: .video) { videoGranted in
    guard videoGranted else {
        completion(nil, "Camera permission denied")
        return
    }
    AVCaptureDevice.requestAccess(for: .audio) { _ in
        // audio denial can be non-fatal; continue without audio input
        self.sessionQueue.async { self.setupCamera(completion: completion) }
    }
}

Vérification

Après lancement via open :

  1. macOS devrait afficher une invite système standard : « YourApp would like to access the camera. » Accordez-la.
  2. L'aperçu caméra devrait apparaître dans l'app dans ~1 seconde (pas de fallback timeout).
  3. log show --last 2m --predicate 'subsystem == "com.apple.TCC"' devrait montrer authorized au lieu de denied, et le processus responsable devrait être l'app elle-même.
  4. Les métadonnées du plugin devraient signaler des valeurs iso et exposureDuration non nulles — confirmant que l'AVCaptureDevice fonctionne réellement.

Si l'aperçu est toujours noir après tout cela, enquêtez séparément (c'est un vrai bug dans le code du plugin, pas un problème TCC).

Exemple de session

Symptôme observé : Application Flutter macOS en révision (PR ajoutant le support caméra macOS natif) lancée via flutter run depuis cmux. L'UI caméra a rendu, le plugin a signalé isInitialized: true, mais l'aperçu était noir et le log montrait DivineCamera macOS: Initialization completed via timeout. Hypothèse initiale (erronée) : le code Swift du plugin manquait un appel AVCaptureDevice.requestAccess.

Exécution diagnostique :

/usr/bin/log show --last 15m --predicate 'subsystem == "com.apple.TCC"' --info \
  | grep -iE "camera|Divine"

Preuve trouvée :

tccd: Prompting policy for hardened runtime; service: kTCCServiceCamera
  requires entitlement com.apple.security.device.camera but it is missing for
  responsible={identifier=com.cmuxterm.app, ...}
  accessing={identifier=Divine, binary_path=.../Divine.app/.../Divine}
tccd: Policy disallows prompt for Sub:{com.cmuxterm.app}...;
  access to kTCCServiceCamera denied

Conclusion : cmux était le processus responsable, pas Divine. Le plugin était innocent — le même échec aurait pu se produire avec n'importe quel plugin caméra.

Correctif appliqué :

cd mobile && flutter build macos --debug
open build/macos/Build/Products/Debug/Divine.app

L'app a demandé la permission caméra au premier lancement, l'utilisateur a accordé, l'aperçu a fonctionné.

Notes

  • Quels émulateurs de terminal sont affectés : Tout terminal hardened-runtime qui ne demande pas l'entitlement caméra. cmux, iTerm2, Terminal.app, Warp, VS Code integrated terminal, Cursor integrated terminal, Android Studio integrated terminal. Essentiellement tous.
  • Donner l'accès caméra au terminal aide-t-il ? En principe oui (System Settings > Privacy & Security > Camera > enable the terminal), mais la plupart des terminaux sont signés hardened-runtime sans l'entitlement caméra donc la case n'est pas offerte. Le workaround open est plus fiable.
  • Pourquoi le message d'erreur est trompeur : Le plugin enregistre « initialization completed via timeout » — ce log est techniquement vrai (la garde init de 2 secondes s'est déclenchée) mais ne dit rien sur POURQUOI aucun cadre n'est arrivé. La vraie réponse n'est que dans le log du sous-système TCC, que la plupart des développeurs ne vérifient jamais.
  • Affecte aussi : Microphone (kTCCServiceMicrophone), screen recording (kTCCServiceScreenCapture), contacts, calendar — toute ressource protégée par TCC lancée via un terminal sans l'entitlement correspondant. Le même workflow log show + open s'applique.
  • Y a-t-il un correctif pour flutter run ? Non. L'outil Flutter invoque l'app comme un sous-processus ; il n'y a aucun moyen de faire en sorte que Flutter-l'outil se retire de la chaîne de responsabilité. Apple ne fournit aucune API pour qu'une app revendique sa propre responsabilité TCC. Vous utilisez soit open, soit acceptez les frictions au premier lancement en développement.
  • Connexe mais différent : flutter-macos-permission-handler-camera-failure couvre l'échec du plugin permission_handler Dart à faire le pont vers TCC. C'est un mode d'échec différent où le plugin lui-même est silencieux ; cette skill couvre TCC étant silencieux avant même que le plugin n'ait une chance de demander.

Références

  • Apple Developer Forums: « App launched from Terminal inherits Terminal's TCC responsibility » (cherchez les posts DTS / Quinn "The Eskimo!" sur TCC).
  • Apple Technical Note TN3127: « Inside Code Signing: Requirements » — couvre l'attribution du processus responsable.
  • man tccutil — syntaxe reset [service] [bundleID].
  • log show --predicate 'subsystem == "com.apple.TCC"' — la source de vérité authoritative pour ce que TCC a réellement fait.

Skills similaires