Swift Package Manager framework creation in Xcode

Swift Package Manager is Apple’s answer for managing dependencies. We’re all familiar with tools like CocoaPods and Carthage but it’s likely that we’ll all use Swift Package Manager in the near future instead of those.

By switching over to the Swift Package Manager (SPM) we also need to know how to create our own frameworks or libraries with SPM support. So let’s dive in and see what it takes to create your own package!

What is the Swift Package Manager?

The Swift Package Manager is introduced in Swift 3.0 and enables us to manage Swift dependencies. You can compare it to tools like CocoaPods and Carthage that also allow you to add dependencies to your project.

Xcode 11 added integrated support to manage your Swift Packages from within Xcode itself. The Package Manager is integrated with the Swift build system to automate the process of downloading, compiling, and linking dependencies. This automation makes it really easy to add and work with external dependencies in your Swift projects.

Can we already switch over completely to the Swift Package Manager?

Although it has definitely improved a lot over the past few years it’s not covering all use-cases yet. At WeTransfer we mostly run into the missing feature of adding resources to a Package. For example, this stops us from adding SPM support to our WeScan framework that includes .strings and png files.

Apart from that, it’s ready to be used. You could eventually start using it on the side next to your other dependency manager. However, it might just be easier to wait for WWDC 2020 that hopefully introduces support for resources as it’s proposal is already accepted in SE-271.

Generating your own Swift Package

You can create your own Swift Package in both the terminal and in Xcode depending on what you like the most.

Note: If you’re looking for a way to create a command-line tool in Swift, take a look at my blog post Creating a command line tool using the Swift Package Manager.

Creating a Swift Package from the Terminal

To create a Package from the terminal you can use the following commands:

$ mkdir SwiftLeePackage
$ cd SwiftleePackage
$ swift package init

In this example, we’re creating a Package with the name “SwiftLeePackage”. After running the init command we will see the steps of creating the library printed out in the terminal:

Creating library package: SwiftLeePackage
Creating Package.swift
Creating README.md
Creating .gitignore
Creating Sources/
Creating Sources/SwiftLeePackage/SwiftLeePackage.swift
Creating Tests/
Creating Tests/LinuxMain.swift
Creating Tests/SwiftLeePackageTests/
Creating Tests/SwiftLeePackageTests/SwiftLeePackageTests.swift
Creating Tests/SwiftLeePackageTests/XCTestManifests.swift

As you can see, it creates all the basic files we need for our own Package, including tests. You can explore your package and run the tests by opening the Package.swift that will open your package in Xcode.

You can also do the same in the Terminal directly be executing the following commands:

$ swift build
$ swift test

Creating a Swift Package in Xcode

The above steps can easily be replicated in Xcode by navigating to File ➞ New ➞ Swift Package.... Give your package the name it needs, save it, and Xcode will directly open your Package to enable you to directly start working with it!

The created Swift Package opened in Xcode
The created Swift Package opened in Xcode

From here you can start building and testing your package as if you were working on a regular Xcode project as you normally do.

Enhancing the default generated Package.swift

After creating your package you’ll see the default generated Package.swift file. This includes the name of your package, it’s products, dependencies, and targets. These are all set to the basics you need for creating a package but you can add a lot more to better describe your package.

But before we go over those options it’s good to know that the first line in the Package file is required to stay.

// swift-tools-version:5.1

It indicates which minimum version of Swift is required to build the Package. Packages that omit this special comment will default to tools version 3.1.0 which is probably not what you want.

Adding supported platforms to a Swift Package file

By default, the Swift Package Manager is assigning a predefined minimum deployment version for each supported platform. If your package is only going to support specific platforms or specific versions of a platform you can add that configuration to your package file using the platforms property.

In the following example, we’re adding support for macOS, watchOS, tvOS, and iOS:

// swift-tools-version:5.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "SwiftLeePackage",
    platforms: [
        // Add support for all platforms starting from a specific version.
        .macOS(.v10_15),
        .iOS(.v11),
        .watchOS(.v5),
        .tvOS(.v11)
    ],
    products: [
        .library(name: "SwiftLeePackage", targets: ["SwiftLeePackage"])
    ],
    targets: [
        .target(name: "SwiftLeePackage", dependencies: []),
        .testTarget(name: "SwiftLeePackageTests", dependencies: ["SwiftLeePackage"])
    ]
)

If you would like to only add support for iOS 11 and up, you can change the platforms property as follows:

platforms: [
    // Only add support for iOS 11 and up.
    .iOS(.v11)
]

This will indicate to implementers that the package does not support all other platforms.

Adding dependencies to your package

It’s likely that you want to add external dependencies to your Swift package. You can do this by making use of the dependencies property of the package and referencing the dependency in the targets you’d like to have access to it.

For example, we could add the Mocker framework as a dependency for our test target:

// swift-tools-version:5.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "SwiftLeePackage",
    platforms: [
        // Only add support for iOS 11 and up.
        .iOS(.v11)
    ],
    products: [
        .library(name: "SwiftLeePackage", targets: ["SwiftLeePackage"])
    ],
    dependencies: [
        /// Define the Mocker dependency:
        .package(url: "https://github.com/WeTransfer/Mocker.git", from: "2.0.0")
    ],
    targets: [
        .target(name: "SwiftLeePackage", dependencies: []),
        /// Add it to your test target in the dependencies array:
        .testTarget(name: "SwiftLeePackageTests", dependencies: ["SwiftLeePackage", "Mocker"])
    ]
)

In this example, we’re adding the Mocker framework and tell the package manager to automatically fetch the version starting from 2.0.0. This allows to fetch versions like 2.0.1 or 2.1.0, but not 3.0.0 as that’s the next major version that’s likely contains breaking changes.

There’s plenty of options here to add specific requirements to your dependency, like using ranges or exact versions:

.package(url: "https://github.com/WeTransfer/Mocker.git", from: "2.0.0"),
.package(url: "https://github.com/WeTransfer/Mocker.git", "2.0.0"..<"2.5.0"),
.package(url: "https://github.com/WeTransfer/Mocker.git", .exact("2.0.0")),
.package(url: "https://github.com/WeTransfer/Mocker.git", .upToNextMajor(from: "2.0.0")),
.package(url: "https://github.com/WeTransfer/Mocker.git", .upToNextMinor(from: "2.0.0"))

Adding a dependency using a specific branch or revision

You can also add specific branches or revisions if you’d like to fetch a dependency that is currently under development or not yet released:

.package(url: "https://github.com/WeTransfer/Mocker.git", .branch("development")),
.package(url: "https://github.com/WeTransfer/Mocker.git", .revision("e74b07278b926c9ec6f9643455ea00d1ce04a021"))

Adding a dependency using a local path for development

Another common use-case is to add a local package during development to easily iterate and test it out. You can do this by specifying the path to the package:

.package(path: "/your/local/package/path")

Removing allTests with automatic test discovery

Exploring the default generated package shows you an XCTestManifests.swift file containing the following code:

import XCTest

#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
    return [
        testCase(SwiftLeePackageTests.allTests),
    ]
}
#endif

As well, within the default generated test class you can find a static array containing all the currently added tests:

static var allTests = [
    ("testExample", testExample),
]

This is added to allow us to run tests on Linux and required us to keep this manually in sync with newly added tests. This is quite a pain to maintain but luckily enough, this is no longer needed!

Swift 5.1 introduced automatic test discovery for Linux which now allows us to run the tests on Linux as follows:

swift test --enable-test-discovery

It also allows us to get rid of the allTests properties and the XCTestManifests.swift file. After deleting those we need to update the LinuxMain.swift file to remove all references to allTests. The class itself is still required as builds on Linux will otherwise fail. To help debugging it might be good to add a fatalError in the LinuxMain.swift file which suggests to run tests using the --enable-test-discovery argument:

fatalError("Running tests like this is unsupported. Run the tests again by using `swift test --enable-test-discovery`")

Publishing your Swift Package

To publish your Swift package you can simply create a new tag on your Git repository. As you’ve seen before in the dependencies section you can add references to dependencies by using Git URLs.

To enable developers to more easily explore packages, Dave Verwer has introduced the Swift Package Index together with Sven A. Schmidt. Adding your package to the library is as easy as submitting a pull request as explained here.

Conclusion

That was it! We’ve created our own Swift Package in both the terminal and in Xcode. We added specific platform requirements and dependencies in multiple different ways. Making use of the automatic test discovery allows us to easily maintain tests for Linux.

You can now continue building your own command-line tool with the knowledge you gained. Check out my blog post Creating a command-line tool using the Swift Package Manager to learn more.

If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.

Thanks!