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. Mais 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, c’est elle qui 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. 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 l’ajout d’un fichier source :

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"

Cela remplace environ 40 lignes de pbxproj. 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 double système de compilation

Il y a deux systèmes de compilation. Ils répondent à des 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 point de départ correct 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 ne sait pas produire de bundles .app. Pas de contournement possible.

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 lui faut 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
    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 : 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. Pas d’avertissement. Pas de solution de repli. Pas de message dans les journaux expliquant ce qui n’a pas marché. Juste du silence et un bouton qui ne fait rien. Un analyseur de disque a besoin de lire des fichiers, c’est évident. Mais déplacer des fichiers vers la Corbeille exige le mode lecture-écriture, pas 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 couvre le binaire, les entitlements et la structure du bundle. Modifiez un seul octet après la signature : macOS refuse de lancer l’application. Lors de la soumission à l’App Store, Apple re-signe l’application avec son propre certificat.

graph TD
    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é à la soumission| 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 Demystifying App Distribution mérite le détour si le sujet est nouveau : elle parcourt toute la chaîne, du certificat de développement à la notarisation.

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 Thomas Tempelmann 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. Se battre contre elle 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.

Au moment de la première soumission App Store, la chaîne de compilation avait cessé d’être 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 siégeait dans le dossier Applications, inactif.

Parfait.