Architecture-based Package Configuration with SPM

Created January 16, 20228 min read

Late last year, I finally switched to using a fancy new M1 MacBook Pro. While it came with plenty of speed and performance improvements (some of my projects that previously built in minutes now build in seconds!), it also came with a number of challenges. For example, even though Homebrew had 70% of its formulae bottled for Apple Silicon by December 2020, there are still a large number of formulae that are incompatible with Apple Silicon.

The problem doesn't stop with Homebrew, of course, as there are also a number of Swift Packages that don't offer Apple Silicon support today. This can be a major blocker if you're trying to move your team onto Apple Silicon architecture. Your first thought might be to look for alternative dependencies that are compatible with Apple Silicon, but many first-party dependencies don't have alternatives, and it can be even more restrictive if it's a dependency maintained by a vendor that your company is already paying for and possibly using in production.

Fortunately, there's a solution to this problem: conditionally stripping the SDK based on the architecture.

Solution

The following code snippet represents a complete Package.swift file.

// swift-tools-version:5.5

import PackageDescription

#if arch(arm64)

let architectureSpecificPackageDependencies: [Package.Dependency] = []
let architectureSpecificTargetDependencies: [Target.Dependency] = []

#else

let architectureSpecificPackageDependencies: [Package.Dependency] = [
    // Your Intel Package dependencies.
    .package(name: "IntelPackage", url: "url/to/intel-package", .upToNextMajor(from: "1.0.0")),
]

let architectureSpecificTargetDependencies: [Target.Dependency] = [
    // Your Intel Target dependencies.
    .product(name: "IntelModule", package: "IntelPackage")
]

#endif

let package = Package(
    name: "MainPackage",
    products: [
        .library(name: "MainModule", targets: ["MainModule"]),
    ],
    dependencies: [
        // Your universal Package dependencies.
        .package(name: "UniversalPackage", url: "url/to/universal-package", .upToNextMajor(from: "1.0.0")),
    ] + architectureSpecificPackageDependencies,
    targets: [
        .target(
            name: "MainModule",
            dependencies: [
                // Your universal Target dependencies.
                .product(name: "UniversalModule", package: "UniversalPackage"),
            ] + architectureSpecificTargetDependencies
        ),
    ]
)

Breakdown

The solution above all works thanks to the arch(arm64) Compiler Control Statement. In the same way that an #if DEBUG statement works, we're informing the compiler to evaluate that block only if the architecture is arm64.

If the architecture is arm64, then we don't have any dependencies to include in the package; however, we could add non-universal, Apple Silicon-only packages in that snippet as well if we needed to include different dependencies for each architecture explicitly. While I haven't needed to do this so far, I could definitely see it being important in the event that the Package.swift file is defining an executable target (such as a command line tool), which may need entirely different packages to support different architectures in different ways.

Where did you get the arm64 value come from?

Well, we know that Apple M1 chips are ARM-based, 64-bit chips, but Apple Silicon may not be arm64 forever. You can find out explicitly what architecture your machine is running on by opening a terminal and running the uname -m command.

First Draft

My first approach worked a little differently: run a shell command to call uname -m at the top of my Package.swift, then test the output to see if it equates to the string arm64.

This solution worked just fine, and it helped me arrive at the solution above, but it is much less optimal than what I ended up with.

I wanted to keep this snippet around for two reasons:

  1. To reinforce the concept that all developers have gaps, no matter what their experience or title. I've been developing on Apple systems for a long time now, I do a lot of command-line development and work with Swift outside of the standard iOS/macOS and Xcode toolchains, and I wasn't aware that there was already an arch Compiler Control Statement I could reference. Everyone has gaps.
  2. There may be other reasons to run shell commands in your Package.swift file, and the snippet below showcases how it could be done in a minimal fashion.
// swift-tools-version:5.5
​
import PackageDescription
import Foundation
​
let architecture = shell("uname -m")?.trimmingCharacters(in: .whitespacesAndNewlines)
let isRunningOnAppleSilicon = architecture == "arm64"
​
let architectureSpecificPackageDependencies: [Package.Dependency] = {
    if isRunningOnAppleSilicon {
        return []
    } else {
        return [
            // Your Intel Package dependencies.
            .package(name: "IntelPackage", url: "url/to/intel-package", .upToNextMajor(from: "1.0.0")),
        ]
    }
}()
​
let architectureSpecificTargetDependencies: [Target.Dependency] = {
    if isRunningOnAppleSilicon {
        return []
    } else {
        return [
            // Your Intel Target dependencies.
            .product(name: "IntelModule", package: "IntelPackage")
        ]
    }
}()

let package = Package(
    name: "MainPackage",
    products: [
        .library(name: "MainModule", targets: ["MainModule"]),
    ],
    dependencies: [
        // Your universal Package dependencies.
        .package(name: "UniversalPackage", url: "url/to/universal-package", .upToNextMajor(from: "1.0.0")),
    ] + architectureSpecificPackageDependencies,
    targets: [
        .target(
            name: "MainModule",
            dependencies: [
                // Your universal Target dependencies.
                .product(name: "UniversalModule", package: "UniversalPackage"),
            ] + architectureSpecificTargetDependencies
        ),
    ]
)
​
// MARK: Utilities
​
func shell(_ command: String) -> String? {
    let task = Process()
    let pipe = Pipe()
    
    task.standardOutput = pipe
    task.standardError = pipe
    task.arguments = ["-c", command]
    task.launchPath = "/bin/sh"
    task.launch()
    
    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)
    
    return output
}