Hello, Xcode : le choc de la chaîne de compilation

Je m’attendais à un compilateur. J’ai découvert un culte.

La première fois qu’on ouvre Xcode, il télécharge des choses. Pas un petit téléchargement rapide. Un téléchargement volumineux, posé, suffisamment long pour qu’on commence à remettre en question ses choix de vie. Puis il s’ouvre, et on se retrouve face à une interface qui contient plus de panneaux qu’on ne l’aurait imaginé, chacun avec son propre vocabulaire, chacun partant du principe qu’on maîtrise déjà les autres.

Je l’ai regardé une trentaine de secondes, n’y ai rien compris, et l’ai refermé.

La plateforme Mac en elle-même ne m’était pas totalement étrangère. J’avais écrit de l’Objective-C et du C++ en contexte professionnel. L’Objective-C ne m’avait jamais paru aussi exotique qu’il aurait dû : j’avais passé des années à écrire du Smalltalk, et [object message] n’est au fond que du Smalltalk avec des crochets et un compilateur C en dessous. Sauf que connaître la plateforme et savoir livrer une application dessus, ce sont deux choses bien différentes.

J’ai écrit Renala dans VS Code. Je l’ai compilé avec xcodebuild en ligne de commande. J’ai ouvert Xcode proprement dit exactement deux fois sur l’ensemble du projet : une fois pour créer le certificat de signature, une fois pour gérer le provisioning profile avant la première soumission App Store. Les deux fois, je l’ai refermé aussi vite que possible.

Xcode l’IDE et Xcode la chaîne de compilation sont deux choses séparables. La chaîne, xcodebuild, xcrun, les outils en ligne de commande, compile effectivement le code, signe le binaire et produit une application distribuable. L’IDE n’est qu’une interface vers cette chaîne. Ce n’est pas la seule.


Le problème .xcodeproj

Le fichier qui représente le projet Xcode s’appelle Renala.xcodeproj. C’est en réalité un répertoire, pas un fichier. À l’intérieur se trouve project.pbxproj : des centaines, voire des milliers de lignes dans un format propriétaire de liste de propriétés qui encode chaque référence de fichier, chaque phase de compilation, chaque paramètre de configuration.

Un extrait représentatif :

/* Begin PBXBuildFile section */
    2B3F1A4C2B9E3A0100C4E8D1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3F1A4B2B9E3A0100C4E8D1 /* ContentView.swift */; };
    2B3F1A502B9E3A0100C4E8D1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2B3F1A4F2B9E3A0100C4E8D1 /* Assets.xcassets */; };
/* End PBXBuildFile section */

Chaque fichier du projet a deux entrées : une qui déclare son existence, une qui déclare sa participation à une phase de compilation (compiler, lier, copier les ressources). Ajoutez un fichier Swift sans mettre à jour le projet : Xcode ne le compile pas ; supprimez un fichier sans mettre à jour le projet : Xcode signale une référence manquante. Le format a été conçu pour être écrit par Xcode, pas par des humains. Il fusionne dans Git à peu près comme une voiture fusionne avec un arbre. Deux personnes qui ajoutent un fichier au même moment produisent un conflit qu’aucun outil de différences ne gère proprement.

La solution évidente : ne pas l’éditer à la main. Laisser un outil le générer.


XcodeGen

XcodeGen est un outil qui lit un fichier project.yml et génère le .xcodeproj. Le YAML est lisible, compatible avec le contrôle de version, et compact. Voici ce que donne la définition d’une cible :

targets:
  Renala:
    type: application
    platform: macOS
    sources:
      - Sources/Renala
    settings:
      PRODUCT_BUNDLE_IDENTIFIER: com.renala.app
      MACOSX_DEPLOYMENT_TARGET: "14.0"
      SWIFT_VERSION: "6.0"

Cette définition de cible remplace ce qui correspondrait dans le pbxproj : références de fichiers, phases de compilation, blocs de configuration. Un paramètre de compilation est une paire clé-valeur ; une capacité est une déclaration, pas une séquence de cases cochées dans un panneau d’IDE.

Le processus : on édite project.yml, on lance make generate, on commite le YAML. Le .xcodeproj est régénéré à la demande et traité comme un artefact de compilation, pas comme un fichier source. Si un conflit de fusion apparaît dans le .xcodeproj, la réponse n’est pas de le résoudre. La réponse est de résoudre le conflit dans project.yml et de régénérer.

Le coût : une dépendance de plus à maintenir, et XcodeGen doit suivre les évolutions de configuration d’Xcode d’une version majeure à l’autre. Jusqu’ici, il a tenu le rythme.


Le double système de compilation

Deux systèmes de compilation, deux problèmes différents.

Swift Package Manager (Package.swift) gère la suite de tests. swift test découvre et exécute les tests sans impliquer Xcode. SPM produit des exécutables et des lanceurs de tests. C’est rapide, scriptable, et taillé pour l’intégration continue. La documentation Swift Package Manager est un bon point de départ si le sujet est nouveau.

Xcode (Renala.xcodeproj, piloté par xcodebuild) produit le bundle .app : signé, sandboxé, avec les bons entitlements, prêt pour la distribution. C’est ce que l’App Store exige. SPM n’a pas de moyen intégré de produire un bundle .app signé, sandboxé et prêt pour l’App Store.1

Les deux systèmes partagent les mêmes fichiers sources sous Sources/Renala/. Le piège : un fichier ajouté dans Sources/ est découvert automatiquement par SPM, mais il faut aussi une entrée dans project.yml pour que le projet Xcode le connaisse. Si on oublie, on obtient une compilation qui passe avec swift test mais échoue avec xcodebuild : déroutant la première fois.

Le diagramme ci-dessous montre comment les pièces s’articulent :

graph LR
    accTitle: Build system workflow
    accDescr: project.yml feeds xcodegen to generate the Xcode project. Package.swift runs tests via swift test. A Makefile unifies all commands: make generate, make build, make test, make run.
    PY[project.yml] -->|xcodegen generate| XP[Renala.xcodeproj]
    PS[Package.swift] -->|swift test| T[Tests]
    XP -->|xcodebuild| APP[Renala.app]
    MF[Makefile] -->|make generate| PY
    MF -->|make build| XP
    MF -->|make test| PS
    MF -->|make run| APP

Le Makefile masque les commandes sous-jacentes derrière un vocabulaire unifié. make build appelle xcodebuild avec les bons drapeaux. make test appelle swift test. make run tue l’instance en cours, compile et lance l’application. Inutile de se souvenir de l’invocation xcodebuild.


Les concepts qui ne se traduisent pas

On déploie un serveur web et il tourne ; on déploie une application macOS et le système demande : qui l’a signée, qu’a-t-elle le droit de faire, peut-on lui faire confiance ? Trois concepts sans équivalent dans le monde backend.

Bundle identifier. Une chaîne DNS inversée qui identifie votre application de manière unique (l’équivalent de l’applicationId Android ou du scope npm) : com.renala.app. Elle relie votre certificat de signature à votre application, votre fiche App Store à votre binaire, votre conteneur iCloud à vos données. On le choisit une fois, on n’en change plus : les systèmes d’Apple l’utilisent partout.

Entitlements. Le manifeste de permissions de votre application. L’App Sandbox commence par tout interdire : aucun accès aux fichiers, aucun réseau, aucune caméra. Chaque capacité nécessaire est déclarée explicitement dans un fichier .entitlements. Voici un sous-ensemble minimal pour un analyseur de disque :

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ...>
<plist version="1.0">
<dict>
    <!-- Requis pour la distribution App Store -->
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <!-- Lecture-écriture (pas lecture seule) : nécessaire pour la mise à la Corbeille -->
    <key>com.apple.security.files.user-selected.read-write</key>
    <true/>
</dict>
</plist>

Si l’entitlement est absent, l’opération échoue à l’exécution. Ni avertissement côté application, ni solution de repli. Le système enregistre un refus de sandbox dans Console.app, mais l’application elle-même ne voit rien.2 Juste le silence et un bouton mort. Un analyseur de disque doit lire des fichiers, évidemment. Mais envoyer des fichiers à la Corbeille exige la lecture-écriture, pas la lecture seule. J’avais configuré le mode lecture seule pendant les deux premières semaines. Le bouton Corbeille ne faisait rien, en silence. La référence Entitlements sur Apple Developer liste toutes les clés disponibles.

Code signing. La chaîne de confiance d’Apple, et ce qui rend les applications macOS inaltérables. Votre application est signée avec un certificat émis par Apple à votre nom en tant que développeur. La signature scelle l’ensemble du bundle : binaire, ressources et entitlements intégrés (intégrés à la signature du code, pas dans un fichier séparé).3

Modifiez le binaire signé et la signature devient invalide. Pour les applications téléchargées depuis internet ou l’App Store, macOS Gatekeeper (le sous-système de sécurité qui vérifie les signatures au lancement) refuse de les exécuter.4 Lors de la soumission à l’App Store, Apple re-signe l’application avec son propre certificat.

graph TD
    accTitle: Code signing chain of trust
    accDescr: Apple Developer Account issues a Developer Certificate, which signs Renala.app. Entitlements are embedded in the app binary. macOS Gatekeeper verifies the signature at launch. The App Store re-signs the app during processing.
    DEV[Compte développeur Apple] -->|émet| CERT[Certificat développeur]
    CERT -->|signe| APP[Binaire Renala.app]
    ENT[Renala.entitlements] -->|intégré dans| APP
    APP -->|vérifié au lancement| MACOS[macOS Gatekeeper]
    APP -->|re-signé lors du traitement| APPSTORE[App Store]

Si la signature ne correspond pas aux entitlements, ou si le binaire a été modifié après la signature, le système refuse de l’exécuter. La documentation Code Signing d’Apple couvre la mécanique ; la session WWDC 2021 Distribute apps in Xcode with cloud signing parcourt la chaîne de distribution si le sujet est nouveau.

Les messages d’erreur quand quelque chose se passe mal sont souvent inutilisables. OSStatus error -67030, ce n’est pas de la documentation. C’est une énigme laissée par une civilisation qui ne souhaite pas être comprise. L’outil de recherche OSStatus de Seth Willits est une référence précieuse pour décoder ces codes.


La leçon

La chaîne de compilation, c’est le développement macOS. La combattre parce qu’elle ne ressemble pas à ce qu’on connaît est une perte de temps. Apprendre son vocabulaire, comprendre pourquoi les choses sont ainsi, rend tout le reste plus fluide.

XcodeGen et le Makefile lui donnent une interface plus saine, qui correspond à la manière de penser d’un développeur venu d’ailleurs. Le .xcodeproj existe toujours. La signature a toujours lieu. Les entitlements sont toujours réels. Le Makefile signifie simplement qu’on n’a pas besoin de se souvenir de l’invocation xcodebuild à vingt drapeaux pour produire une archive de distribution.

À la première soumission App Store, la chaîne de compilation n’était plus une source de friction. Le Makefile gérait les commandes, XcodeGen gérait le fichier projet, et je restais dans VS Code pour tout le reste. J’ouvrais Xcode quand il le fallait ; le reste du temps, il dormait dans le dossier Applications.

Parfait.

Footnotes

  1. Des outils communautaires comme swift-bundler peuvent produire des bundles .app par-dessus SPM pour la distribution directe, mais aucun chemin officiel Apple n’existe au sein de SPM pour la chaîne complète App Store (signature, sandboxing, entitlements, provisioning).

  2. Le système enregistre bien les refus de sandbox. Dans Console.app, filtrez par subsystem == "com.apple.sandbox" et vous verrez des entrées DENY avec l’opération refusée et le chemin. Le problème est que l’application elle-même ne reçoit ni erreur, ni exception, ni indication qu’un refus de sandbox s’est produit. Il faut savoir chercher dans Console.

  3. Les entitlements ne sont pas un fichier séparé que la signature « couvre ». Lors de la signature du code, les entitlements de votre fichier .entitlements sont intégrés dans le blob CMS de signature attaché au binaire. À l’exécution, macOS lit les entitlements depuis la signature, pas depuis un fichier sur disque.

  4. Le comportement de Gatekeeper dépend du contexte. Il effectue une vérification complète de la signature sur les applications en quarantaine (téléchargées depuis internet). Pour les applications compilées localement ou déjà approuvées, les lancements courants peuvent ne pas revérifier la signature cryptographique complète. L’application la plus stricte concerne les applications distribuées, le scénario décrit ici.