5 years ago

Interacting with Xcode projects in Swift

There are some scenarios where it might be useful to do some automation on those files, for example, to detect references to files that don’t exist, or invalid build settings. Even though you could check those things by parsing the file yourself and traversing the structure, you can do it more conveniently with xcodeproj. It not only provides you with an API in Swift, but ensures that your changes in the project file are persisted with the format that Xcode.

In this blog post, I’ll talk about the project and its structure, and jump into some examples where you might consider introducing some automation in your projects with xcodeproj.

Note that APIs might have changed since the I wrote this blog post. If the examples don’t run as expected, I recommend checking out the documentation on the repository.

Xcodeproj, a monolithic format

The Xcode project, which has an extension xcodeproj (where the name of the library comes from), is a folder that contains several files that define different components of the project. One of the most interesting and complex files is the file project.pbxproj. You might be familiar with it if you have run into git conflicts on Xcode projects before. This is a property list file, like the Info.plist, but with a subtle difference that made implementing xcodeproj a challenge. The file has some custom annotations that Xcode adds along the file to make the format more human-readable and (I’m guessing) facilitate resolving git conflicts. Since the format is not documented, the library required several iterations to approximate the format of Xcode accurately.

The pbxproj file contains a large list of objects, which in xcodeproj are modeled as PBXObject classes. They represent elements such as build phases (PBXBuildPhase), targets (PBXNativeTarget) or files (PBXFileReference). Those objects get a unique identifier (UUID) when Xcode creates them, and it’s used to declare references between objects. For example, a target has references to its build phases using their UUIDs as shown in the example below:

buildPhases = (
OBJ*593 /* Sources _/,
OBJ_599 /_ Frameworks \_/,
);

The example above is from a project generated by the SPM. SPM has its own convention for naming UUIDs, which doesn’t match Xcode’s default.

For projects like the SwiftPM or Tuist, which leverage project generation, it’s crucial to generate the UUIDs deterministically. In other words, every time a project is generated, its objects always get the same UUIDs. Otherwise, Xcode and its build system would invalidate the cache and cause builds to start from a clean state. xcodeproj uses the object attributes and the attributes of its parents to make the generated id deterministic. Moreover, the format is better aligned with the one that Xcode uses.

An undocumented format

Conversely to Android applications that are built using Gradle, a build system that is extensively documented, the format of Xcode projects lacks documentation. Apple expects Xcode to be the interface to the projects. Consequently, they barely put effort into documenting the project structure or making it more declarative and git-friendly.

So if the format is not documented, how were we able to develop a Swift library that works as an alternative interface to the projects? First and foremost, thanks to the pioneering work that the fantastic CocoaPods team did in that area. They developed the first ever library to read, update and write Xcode projects, xcodeproj. The library is written in Ruby and is a core component of CocoaPods.

The work of understanding the project pretty much of reverse-engineering how Xcode maps actions to changes into the project files. To give you an example, let’s say that we’d like to understand how the linking of a new library reflects on the project files.

  1. We commit the current state of the project so that we can use git to spot the diff.
  2. Change the target settings to link the library.
  3. Use git diff and see what changed.

CocoaPods already did some of that work, but that did not prevent us from having to do it as well. For instance, we wanted to expose as optionals the attributes that are optionals in projects. How did we know which attributes were optionals? We removed them from the project and tried to open the project with Xcode. If Xcode was able to open the project, that indicated that the attribute was optional. If Xcode crashed, it meant that the attribute was required. Do you imagine doing that with every attribute of each object? It was a vast amount of work, but luckily something that we don’t have to do often because new Xcode versions barely introduce new attributes.

Hands-on examples

I could write a blog post explaining each of the objects and get you weary with some theory, but I thought it’d be better to take you through some practical examples that you could write yourself to get familiar with the objects. Before we dive into them, we need to create a new Swift executable package where we’ll add xcodeproj as a dependency. Let’s create a folder and initialize a package:

mkdir examples
cd examples
swift package init --type executable

The commands above will create a manifest file, Package.swift, and a Sources/examples directory with a main.swift file where we’ll write our examples. Next up, we need to add xcodeproj as a dependency. Edit the Package.swift and add the following dependencies to the dependencies array:

.package(url: "https://github.com/tuist/xcodeproj.git", .upToNextMajor(from: "6.5.0")),

Replace 6.5.0 with the version of xcodeproj that you’d like to use.

Alternatively, you can use swift-sh, a handy tool that facilitates the definition of Swift scripts with external dependencies. The only thing you need to do is to install the tool, which can be done with Homebrew by running brew install swift-sh and create a Swift script where you’ll code the examples:

#!/usr/bin/swift sh

import Foundation
import xcodeproj // tuist/xcodeproj
import PathKit // kylef/PathKit

That’s all we need to start playing with the examples.

Example 1: Generate an empty project

In this example, we’ll write some Swift lines to create an empty Xcode project. Exciting, isn’t it? If you ever wondered what Xcode does when you click File > New Project, you’ll learn it with this example. You’ll realize that after all, creating an Xcode project is not as complicated as it might seem. You could write your own Xcode project generator. Let me dump some code here and navigate you through it right after:

import Foundation
import PathKit
import xcodeproj

// 1 .pbxproj
let pbxproj = PBXProj()

// 2. Create groups
let mainGroup = PBXGroup(sourceTree: .group)
pbxproj.add(object: mainGroup)
let productsGroup = PBXGroup(children: [], sourceTree: .group, name: "Products")
pbxproj.add(object: productsGroup)

// 3. Create configuration list
let configurationList = XCConfigurationList()
pbxproj.add(object: configurationList)
try configurationList.addDefaultConfigurations()

// 4. Create project
let project = PBXProject(name: "MyProject",
buildConfigurationList: configurationList,
compatibilityVersion: Xcode.Default.compatibilityVersion,
mainGroup: mainGroup,
productsGroup: productsGroup)
pbxproj.add(object: project)
pbxproj.rootObject = project

// 5. Create xcodeproj
let workspaceData = XCWorkspaceData(children: [])
let workspace = XCWorkspace(data: workspaceData)
let xcodeproj = XcodeProj(workspace: workspace, pbxproj: pbxproj)

// 6. Save project
let projectPath = Path("/path/to/Project.xcodeproj")
try xcodeproj.write(path: projectPath)

Let’s break that up analyze block by block:

  1. A PBXProj represents the project.pbxproj file contained in the project directory. The constructor initializes it with some default values expected by Xcode and an empty list of objects.
  2. PBXGroup objects represent the groups that one can see in the project navigator. Projects required two groups to be defined, the mainGroup which represents the root of the project and where other will groups will be added as children, and the productsGroup which is the group where Xcode creates references for all your project products (e.g. apps, frameworks, libraries)
  3. Projects and targets need what’s called a configuration list, XCConfigurationList. A configuration list groups configurations like Debug and Release and ties them to a project or target. The call to the method addDefaultConfigurations creates the default build configurations, represented by the class XCBuildConfiguration. A XCBuildConfiguration object has a hash with build settings, and a reference to an .xcconfig file, both optional.
  4. Next up, we need to initiate a PBXProject which contains project settings such as the configuration list, the name, the targets, and the groups.
  5. Last but not least, we need to create an instance of a XcodeProj which represents the project that is written to the disk. If you explore the content of any project, you’ll realize that it contains a workspace. Therefore the XcodeProj instance needs the workspace attribute to be set with an object of type XCWorkspace.
  6. Changes need to be persisted into the disk by calling write on the project and passing the path where we’d like to write it.

Notice that the objects that are created to be part of the project need to be added to the pbxproj.

If you run the code above, you’ll get an Xcode project that works in Xcode. However, it does not contain any target or schemes that you can work with. The goal with this example was to give you a sense of what the xcodeproj API and Xcode projects look like. Using xcodeproj to generate your company’s projects would require much work so unless there’s a good reason for it, you can use tools like XcodeGen or Tuist instead. Those tools allow you define your projects in a different format, for example, yaml or Swift, and they convert your definition into an Xcode project. The resulting definition file is much simpler and human-readable than Xcode’s .pbxproj

Example 2: Add a target to an existing project

Continuing with examples that help you understand the project’s structure, we’ll add a target to an existing project. Like I did with the preceding example, I’ll introduce you to the code first:

import xcodeproj
import PathKit

// 1. Read the project
let path = Path("/path/to/Project.xcodeproj")
let project = try XcodeProj(path: path)
let pbxproj = project.pbxproj
let targetName = "MyFramework"
let pbxProject = pbxproj.projects.first!

// 2. Create configuration list
let configurationList = XCConfigurationList()
pbxproj.add(object: configurationList)
try configurationList.addDefaultConfigurations()

// 3. Create build phases
let sourcesBuildPhase = PBXSourcesBuildPhase()
pbxproj.add(object: sourcesBuildPhase)
let resourcesBuildPhase = PBXResourcesBuildPhase()
pbxproj.add(object: PBXResourcesBuildPhase())

// 4. Create the product reference
let productType = PBXProductType.framework
let productName = "\(targetName).\(productType.fileExtension!)"
let productReference = PBXFileReference(sourceTree: .buildProductsDir, name: productName)
pbxproj.add(object: productReference)
pbxProject.productsGroup?.children.append(productReference)

// 5. Create the target
let target = PBXNativeTarget(name: "MyFramework",
buildConfigurationList: configurationList,
buildPhases: [sourcesBuildPhase, resourcesBuildPhase],
productName: productName,
product: productReference,
productType: productType)
pbxproj.add(object: target)
pbxProject.targets.append(target)

try project.write(path: path)
  1. The first thing that we need to do is read the project from disk. XcodeProj provides a constructor that takes a path to the project directory. xcodeproj decodes the project and its objects. Notice that we are assuming that the pbxproj contains at least a project. If nothing has been messed up with the project that’s always the case.
  2. Like we did when we generated the project, targets need configurations. We are not defining any build settings, but if you wish, I recommend you to explore the constructors of the classes. You’ll get to see all the configurable attributes.
  3. A target has build phases. xcodeproj provides classes representing each of the build phases supported by Xcode, all of them following the naming convention PB---BuildPhase. In our example, we are creating two build phases for the sources and the resources.
  4. Targets need a reference to their output product. It’s the file that you see under the Products directory when you create a new target with Xcode. It references the product in the derived data directory. Since we are creating the target manually, we need to create that reference ourselves. For that, we use an object of type PBXFileReference. The name is initialized with two attributes, the name, and the sourceTree which defines the parent directory or the association with its parent group. You can see all the possible values that sourceTree can take. In the case of the target product, the file must be relative to the build products directory. Don’t forget to add the product as a child of the project products group.
public enum PBXSourceTree {
  case none
  case absolute // Absolute path.
  case group // Path relative to the parent group.
  case sourceRoot // Path relative to the project source root directory.
  case buildProductsDir // Path relative to the build products directory.
  case sdkRoot // Path relative to the SDK directory.
  case developerDir // Path relative to the developer directory.
  case custom(String) // Custom path.
}

With all the ingredients to bake the target, we can create the instance and add it to the project. Write the project back to disk and open the project. Voila 🎉! A new target shows up in your project.

Note: A pbxproj can contain more than one project when an Xcode project is added as sub-project of a project. In that case Xcode adds the project as a file reference and then adds the reference to the pbxproj.projects attribute.

Example 3: Detect missing file references

If you have solved git conflicts before in your Xcode projects, you might already know that sometimes, you end up with files in your build phases that reference files that don’t exist. Most times, Xcode doesn’t let you know about it, and you end up with a project in a project in a not-so-good state. What if we were able to detect that before Xcode even tries to compile your app?

import xcodeproj
import PathKit

let path = Path("/path/to/project.xcodeproj")

let project = try XcodeProj(path: path)
let pbxproj = project.pbxproj
let pbxProject = pbxproj.projects.first!

/// 1. Get build phases files
let buildFiles = pbxproj.nativeTargets
  .flatMap({ $0.buildPhases })
  .flatMap({ $0.files })

try buildFiles.forEach { (buildFile) in
/// 2. Check if the reference exists
guard let fileReference = buildFile.file else {
fatalError("The build file \(buildFile.uuid) has a missing reference")
return
}

/// 3. Check if the references an existing file
let filePath = try fileReference.fullPath(sourceRoot: path.parent())
if filePath?.exists == false {
  fatalError("The file reference \(fileReference.uuid) references a file that doesn't exist")
}
  1. Projects have an attribute, nativeTargets, that returns all the targets of the project. From each target, we can get its list of build phases accessing the attribute buildPhases. Build phases are objects of the type PBXBuildPhase which expose an attribute, files with the files that are part of the build phase. Build phase files, build files, are represented by the class PBXBuildFile.
  2. The first thing that we do is checking if the file reference exist. Notice that we are accessing the file attribute from the build file. That’s because a build file is a type that works as a reference to a file from your project groups. The same file represented by its PBXFileReference object, can be referenced from multiple build phases resulting in multiple PBXBuildFiles but just one PBXFileReference. We check whether the file reference exists. If it doesn’t, it probably means that we didn’t solve the git conflict correctly and removed a reference that was being referenced by a build file.
  3. After checking if the file reference exists, we check that the file reference points to an existing file. We can do that by obtaining the absolute path calling the method fullPath on the file reference. Notice that we need to pass a sourceRoot argument, which is the directory that contains the project.

Example 4: Detecting if a Info.plist is being copied as a resource

Another typical scenario when working with Xcode projects, is when we add a file to the copy resources build phase when it shouldn’t be there. An excellent example of this one is copying the Info.plist file. Have you been there before? Fortunately, we can leverage xcodeproj to detect that as well.

import xcodeproj
import PathKit

let path = Path("/pat/to/project.xcodeproj")

// Read the project
let project = try XcodeProj(path: path)
let pbxproj = project.pbxproj
let pbxProject = pbxproj.projects.first!

try pbxproj.nativeTargets.forEach { target in
// 1. Get the resources build phase
let resourcesBuildPhase = try target.resourcesBuildPhase()

resourcesBuildPhase?.files.forEach { buildFile in
guard let fileReference = buildFile.file else { return }

/// 2. Check if the path or name reference an Info.plist file
if fileReference.path?.contains("Info.plist") == true ||
  fileReference.name?.contains("Info.plist") == true {
  fatalError("The target \(target.name) resources build phase is copying an Info.plist file")
}
  1. We can obtain the resources build phase from a target calling the convenience method resourcesBuildPhase(). If the build phase doesn’t exist, it’ll return a nil value.
  2. As we did in the previous example, we get the file reference of each build phase, and we check whether the name or the path contain Info.plist. If they do, we let the developer know.

Note that checking the name of the file being Info.plist is not enough cause it might be a file that is not the target Info.plist. If we want to be more precise, we’d need to check if it references the same file as the INFOPLIST_FILE build setting. For the sake of simplicity, we only check one thing in the example.

Ensuring a healthy state in your projects

As we’ve seen, with a few lines of Swift, we can implement relatively simple checks that can be run as part of the local development or as a build step on CI to make sure that your projects are in a good state. Thanks to xcodeproj you can do it in a language that you are familiar with, Swift.

Having your projects in a good state is crucial to make your builds reproducible and avoid unexpected compilation issues that might arise later as a result of a bad merge that went unnoticed.

Projects powered by xcodeproj

Before closing the blog post, I’d like to give you some examples of tools that leveraged xcodeproj to make your life easier as a developer.

I hope after reading this blog post you have a better sense of how Xcode projects are structured, and how even though Xcode doesn’t expose any API for you to read/update your projects, you can leverage a tool like xcodeproj to do so.

About Pedro Piñera

I created XcodeProj and Tuist, and co-founded Tuist Cloud. My work is trusted by companies like Adidas, American Express, and Etsy. I enjoy building delightful tools for developers and open-source communities.