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.
- We commit the current state of the project so that we can use git to spot the diff.
- Change the target settings to link the library.
-
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:
-
A
PBXProj
represents theproject.pbxproj
file contained in the project directory. The constructor initializes it with some default values expected by Xcode and an empty list of objects. -
PBXGroup
objects represent the groups that one can see in the project navigator. Projects required two groups to be defined, themainGroup
which represents the root of the project and where other will groups will be added as children, and theproductsGroup
which is the group where Xcode creates references for all your project products (e.g. apps, frameworks, libraries) -
Projects and targets need what’s called a configuration list,
XCConfigurationList
. A configuration list groups configurations likeDebug
andRelease
and ties them to a project or target. The call to the methodaddDefaultConfigurations
creates the default build configurations, represented by the classXCBuildConfiguration
. AXCBuildConfiguration
object has a hash with build settings, and a reference to an.xcconfig
file, both optional. -
Next up, we need to initiate a
PBXProject
which contains project settings such as the configuration list, the name, the targets, and the groups. -
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 theXcodeProj
instance needs the workspace attribute to be set with an object of typeXCWorkspace
. -
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)
-
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 thepbxproj
contains at least a project. If nothing has been messed up with the project that’s always the case. - 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.
-
A target has build phases.
xcodeproj
provides classes representing each of the build phases supported by Xcode, all of them following the naming conventionPB---BuildPhase
. In our example, we are creating two build phases for the sources and the resources. -
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 typePBXFileReference
. The name is initialized with two attributes, the name, and thesourceTree
which defines the parent directory or the association with its parent group. You can see all the possible values thatsourceTree
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 thepbxproj.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")
}
-
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 attributebuildPhases
. Build phases are objects of the typePBXBuildPhase
which expose an attribute,files
with the files that are part of the build phase. Build phase files, build files, are represented by the classPBXBuildFile
. -
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 itsPBXFileReference
object, can be referenced from multiple build phases resulting in multiplePBXBuildFile
s but just onePBXFileReference
. 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. -
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")
}
-
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 anil
value. -
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 targetInfo.plist
. If we want to be more precise, we’d need to check if it references the same file as theINFOPLIST_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.
- Tuist: Tuist is a tool that helps you define, maintain, and interact with your Xcode projects at any scale. It’s the project that motivated the development of xcodeproj.
- Cake: A delicious, quality‑of‑life supplement for your app‑development toolbox.
- XcodeGen: A Swift command line tool for generating your Xcode project
- AutoEnvironment: Tool to automatically generate Environment.swift based on Xcode project.
- Deli: Deli is an easy-to-use Dependency Injection(DI).
- Accio: A dependency manager driven by SwiftPM that works for iOS/tvOS/watchOS/macOS projects.
- xccheck: A diagnostic tool for Xcode projects.
- expel: Automatically move your Xcode project build settings to xcconfig files.
- xcodemissing: A tool to find and delete files that are missing from Xcode projects.
- xcodeproj-modify: Adds a Run Script phase to an Xcode project.
- Templar: A template generator.
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.