Categories
iOS Development

Using SPM for Xcode build phase tools

A tutorial on setting up build tools for your xcode project using only the Swift Package Manager.
(No more cocoapods 🤓)

Since the introduction of Swift Package Manager (SPM) support for iOS projects, I’ve been avoiding the use of cocoapods in my newer projects. This works great for incorporating packages into the app, however there is very little info out there on using SPM to add executable dependencies that are not included in the app itself. A key example of this is build tools that use a Run script Build Phase (eg. SwiftFormat, R.swift, SwiftLint)

Today I’ll provide a walkthrough of using SPM to add such tools to your project.

Getting Started

In order to define our build tools using SPM we’re going to:

  1. Define what tools we need available
  2. Run these tools in a Run Script build phase from our xcodeproj

I’ll use SwiftFormat as an example here, however the principles hold for any build tool that uses a Run Script phase. I’ll also provide a worked example of adding R.swift to a project

Setting up our dependencies

First we’re going to create a folder called BuildTools in the same directory as YourApp.xcodeproj.

Next, in the new BuildTools folder, create a Package.swift file with the following content:

// swift-tools-version:5.1
import PackageDescription

let package = Package(
    name: "BuildTools",
    dependencies: [
        //Define any tools you want available from your build phases
        //Here's an example with SwiftFormat
        .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.41.2"),
    ],
    targets: [.target(name: "BuildTools", path: "")]
)

Xcode 11.4 onward:

A change in Swift Package Manager means we need at least one valid target in our package, which unfortunately requires a swift file.

Create an empty swift file in the BuildTools folder with any name. eg Empty.swift

Defining our Run Script Build phase

For many build tools, the easiest use case is to run them automatically on building your main app target.

Alternatively, if you’d rather choose when to run the scripts, you could define this in a test target (which you can build whenever you want the script to run)

  1. Click on your project in the file list, choose your target under TARGETS, click the Build Phases tab
  2. Add a new Run Script Phase by clicking on the plus icon in the top-left of the tab
  3. Move the Run Script Phase to above your Compile Sources phase (Note: depending on the nature of the tool, you might want a different order)
  4. Expand the run script and paste the following:
cd BuildTools
SDKROOT=macosx

#Temporarily uncomment the following line to update your packages (based on the versions defined in BuildTools/Package.swift)
#swift package update

#Run your build tool - using swiftformat as an example
swift run -c release swiftformat "$SRCROOT"

All done! SwiftFormat will now run whenever you build the target (keeping your source code nice and neat!)

Worked example for R.swift

R.swift is a helpful codegen tool that generates strong typed resource accessors. Using these generated accessors means that the existence of your assets is checked at compile time, and you get auto-completion!

Firstly, we would add R.swift to our BuildTools/Package.swift file:

// swift-tools-version:5.1
import PackageDescription

let package = Package(
    name: "BuildTools",
    dependencies: [
        .package(url: "https://github.com/mac-cain13/R.swift", from: "5.1.0"),
    targets: [.target(name: "BuildTools", path: "")]
    ]
)

Next we would add a Run script build phase, dragging it before ‘Compile Sources’:

cd BuildTools
SDKROOT=macosx
swift run -c release rswift generate "$SRCROOT/R.generated.swift"

R.Swift uses Input / Output files so we’ll add these to the run script:

Input Files: $TEMP_DIR/rswift-lastrun
Output Files: $SRCROOT/R.generated.swift

Now build the target, and the R.generated.swift file will be created.

Next add this file to your Xcode Project. Make sure to untick ‘copy if needed’ – this will ensure the project points to the original file (which will get automatically updated)

The generated file (R.generated.swift) refers to types in a module called R.Swift.Library, so we’ll add this to our target:

  1. Go to File -> Swift Packages -> Add Package Dependency
  2. Paste the url of the ‘R.swift.Library‘ repository: https://github.com/mac-cain13/R.swift.Library
  3. Select version: ‘up to next major’ and press Next
  4. Tick any targets that you’ll use R.generated.swift from.

That’s it! 🎉 You’ve set up R.Swift, and the generated file will be automatically updated when you build your target.

Conclusion

I am quite satisfied with this approach for the following reasons:

  1. Your build tool dependencies are defined in a Package.swift file and this can be checked into source control.
  2. Other than the Run Script build phase, this requires zero changes to your xcodeproj
  3. The tools will automatically be downloaded when someone builds the project the first time (no installation required). It also ensures the correct version is used.

The very first build will take a while as the dependency is downloaded and built, however the swift run command intelligently reuses the build unless a new version is available and swift package update is called.

Please comment if you have any suggestions or feedback 😃

7 replies on “Using SPM for Xcode build phase tools”

I’ve tried this in the past and run into problems.

If you have frameworks in your project that LLBuild tries to build in parallel, that are calling SPM in build phase scripts, SPM will contend on locked files resulting in build failures and other file corruption.

It might work fine in a simple project with just an app target but once an app starts to be modularised into frameworks it fails.

This is so awesome. I’m new to Swift. Learning it for work. Had to set up SwiftFormat for my first sprint. Searched all over for something like this. Thank you so much 🙂

The `swift run` command is smart enough to only rebuild if the package changes 👍🏻 So the package will be pulled and built on the first run, however all subsequent ones only run the executable.

Hey Tobeas!
Thanks for a great article. R.swift integration described here worked perfectly for me for a month, but then Xcode 11.4 arrived and ruined it all. Have you found a way to get it work with it? I keep getting various errors, like “unable to load standard library for target ‘x86_64-apple-macosx10.10”. It can’t be just me?

Thanks for pointing this out. I just downloaded Xcode 11.4 beta and found the same issue. There have been some changes to SPM that unfortunately require some extra steps, I have updated the article. I will investigate if there is a simpler way around this in the coming days.

Changes needed:
1) Add an empty target to your `Package.swift` file:
targets: [.target(name: "BuildTools", path: "")]
2) Make an empty swift file in the BuildTools folder (doesn’t matter what it is called)
3) Add SDKROOT=macosx to your build phase script (before the `swift run` command)

I’d love to shake your hand if there was no corona virus out there and it hasn’t been prohibited here 🙂 The updated solution works like a charm and looks like pure magic to me.

Leave a Reply

Your email address will not be published. Required fields are marked *