Ferrostar: Building a Cross-Platform Navigation SDK in Rust (Part 2 - iOS Packaging)

It's been a while since our first deep dive into the tech behind Ferrostar, our new turn-by-turn navigation SDK. As a recap, our last post covered why we're writing the core in Rust, how we approached both code and data model sharing, and looked at the architecture from a high level.

Writing a some code for private use is one thing, but publishing it for others to use can be maddeningly difficult. When the time came for us to publish Swift Packages, we found something closer to the latter.

In this post, we'll cover the practical details of how we cross-compiled the Rust library for iOS, packaged it in an XCFramework, and published it as a Swift Package. This is the missing manual we wish we had.

A screenshot of Ferrostar navigating on an iPhone

Swift Packaging

Let's start off by looking at the Swift Package structure. We'll need to add two targets to our Package.swift: a binary target for the static library, and a Swift target for the generated bindings.

Binary targets in Swift Package Manager can be tricky, and this complexity makes our Package.swift file a bit unwieldy from the start.

let binaryTarget: Target
let useLocalFramework = false  // NB: Set this to true when developing locally!

if useLocalFramework {
    binaryTarget = .binaryTarget(
        name: "FerrostarCoreRS",
        // IMPORTANT: Swift packages importing this locally will not be able to
        // import Ferrostar core unless you specify this as a relative path!
        path: "./common/target/ios/libferrostar-rs.xcframework"
    )
} else {
    // Git stuff which we'll come back to at the end.
    let releaseTag = "0.23.0"
    let releaseChecksum = "fa308b519db5424d73d00d60ca03fc18c1dcf2f88704aadce29259d12f2de2b2"
    binaryTarget = .binaryTarget(
        name: "FerrostarCoreRS",
        url:
        "https://github.com/stadiamaps/ferrostar/releases/download/\(releaseTag)/libferrostar-rs.xcframework.zip",
        checksum: releaseChecksum
    )
}

Unfortunately, we haven't found a way to use the same Package.swift unmodified for both local development within the "project" and for publishing. (To be fair, this is an extremely rare feature, but it's worth noting that Cargo workspaces let you do this seamlessly with Rust crates.) So, we need to add some switching logic to our package definition.

The above is the solution we came up with. We always ensure that useLocalFramework = false for the version checked in to git. Published versions will need to point to the released artifact, or else we'd require every developer to check out and build the whole project locally.

For local development and CI builds, where you may need unreleased changes in the Rust core, you have to override the value to true. If anyone knows a cleaner solution to this, let us know!

Another complicating detail is that you need a checksum for remote binary downloads. This is a safety measure built into the Swift Package Manager to prevent supply chain attacks. Since the checksum can't be known until build time, we have CI rewrite this line in Package.swift when publishing a release. (We'll cover this dance later.)

One target down... the other is fortunately a bit simpler. We've called this FerrostarCoreFFI in our project, to make the distinction clear between the FFI bindings and the Rust binary.

.target(
    name: "FerrostarCoreFFI",
    dependencies: [.target(name: "FerrostarCoreRS")],
    path: "apple/Sources/UniFFI"
)

This is pure Swift and doesn't have any surprises. It just depends on the binary target, and includes a source directory which will contain the generated bindings.

You'll need to add both the source and binary target to your list of targets. You can now reference them by name from any of your other targets, and/or publish them via products.

Cross compilation

Now that we've looked at the package structure, let's see what actually goes into the two targets.

We'll tackle cross compilation first. We're going to compile the Rust code for all the relevant iOS target architectures, just like Xcode does for our Swift projects.

Cross compilation is quite tricky (maybe even traumatic) in some languages, but Cargo is full of pleasant surprises.

cargo build --lib --release --target some-target-triple

That's it! Just specify the target triple! For iOS, we actually need to compile for three targets:

Target tripleDescription
x86_64-apple-iosSimulator for Intel-based Macs
aarch64-apple-ios-simSimulator for ARM-based Macs
aarch64-apple-iosiOS devices

If you try running this on your machine right now, you may get an error since you need to have the target toolchain installed. Fortunately, this is easy. If you're using rustup, you can add any target with the command rustup target add your-target-triple.

For iOS, there are just three targets, but we'll have a LOT more by the time we're done with this series. Remembering all of these is a lot of work, but there's a relatively under-appreciated file that can help us out: rust-toolchain.toml.

If you're using rustup to install cargo, it will automatically install the relevant toolchains for you! We can check this into git and call it a day. Here's the full rust-toolchain.toml for Ferrostar.

While this file is specifically designed for use with rustup, other build systems often have a way of consuming it too. For example, Nix users could leverage fenix.fromToolchainFile to get the same effect.

Generating the FFI Bindings

Now that we have the static library built in its first form, we need an easy way for Swift to interface with it. It's UniFFI's time to shine!

Since our first post, UniFFI has evolved a uniffi-bindgen-swift crate concept with a slightly different CLI. Not a lot has changed, but it's worth a quick note in case you're on an older version. If you're starting fresh, this starter template has everything ready to go with the new setup.

Here's how we generate the bindings:

  1. Run uniffi-bindgen-swift via cargo run -p.
  2. Copy the generated Swift file to the appropriate source directory (the FerrostarCoreFFI target's source path in our example).
  3. Copy the clang module map into a framework staging directory.

Here's the relevant portion of our build script, where $1 is the name of the library (ferrostar in our case).

cargo run -p uniffi-bindgen-swift -- target/aarch64-apple-ios/release/lib$1.a target/uniffi-xcframework-staging --swift-sources --headers --modulemap --module-name $1FFI --modulemap-filename module.modulemap
mv target/uniffi-xcframework-staging/*.swift ../apple/Sources/UniFFI/
mv target/uniffi-xcframework-staging/module.modulemap target/uniffi-xcframework-staging/module.modulemap

uniffi-bindgen-swift looks at the static library and generates everything we need to smoothly interact with it from Swift. This includes enums, protocols, library loading code, etc.; this is where it does the magic that makes the Rust library feel like a native Swift package. We just need to copy everything into the location we said in our Package.swift.

NOTE: It is possible to integrate these steps into Xcode. However, given the relative difficulty of doing this and the overall flakiness of the Xcode build process, we opted for a simple, reliable shell script. This does mean you need to manually rebuild when changing Rust files, but that's a small step.

Building a "fat" library (universal binary)

As a last step before creating the XCFramework, we need to do something a bit funny and fuse two of our three binary targets together.

The way that XCFramework is designed, it expects a single binary for each platform, not each CPU architecture. And we have two binaries for the iOS Simulator platform: one for the newer Apple Silicon and one for Intel.

To get down to one binary per platform (simulator and device), we'll use an old tool, lipo to generate a "fat" binary with both architectures in a single file.

lipo -create target/x86_64-apple-ios/release/lib$1.a target/aarch64-apple-ios-sim/release/lib$1.a -output target/ios-simulator-fat/release/lib$1.a

Generating the XCFramework

Now we finally put all of this together in an XCFramework. Some teams actually do this by hand, since it's a relatively simple structure, but we'll stick to Apple's official tooling.

xcodebuild -create-xcframework \
    -library target/aarch64-apple-ios/release/lib$1.a -headers target/uniffi-xcframework-staging \
    -library target/ios-simulator-fat/release/lib$1.a -headers target/uniffi-xcframework-staging \
    -output target/ios/lib$1-rs.xcframework

This command combines the two static libraries, header, and module map into a single directory. And if all went well, we now have a working Swift package that you can test locally!

Distributing the XCFramework

Now it's time to revisit the git and checksum dance that we glossed over at the start.

Here's our distribution checklist:

  1. Zip up the folder.
  2. Compute a checksum for the archive.
  3. Update Package.swift with your release tag and checksum.

We script this in our CI actions like so:

ditto -c -k --sequesterRsrc --keepParent target/ios/lib$1-rs.xcframework target/ios/lib$1-rs.xcframework.zip
checksum=$(swift package compute-checksum target/ios/lib$1-rs.xcframework.zip)
version=$(cargo metadata --format-version 1 | jq -r --arg pkg_name "$1" '.packages[] | select(.name==$pkg_name) .version')
sed -i "" -E "s/(let releaseTag = \")[^\"]+(\")/\1$version\2/g" ../Package.swift
sed -i "" -E "s/(let releaseChecksum = \")[^\"]+(\")/\1$checksum\2/g" ../Package.swift

ditto is an archiving utility found on all macOS systems. We use this to create the ZIP archive. Then, we compute the checksum using swift package compute-checksum. This utility is included in the Xcode Command-line Tools. For versioning, our repository prefers to keep all platforms in sync, and derives all versions from the Rust project using a bit of CLI magic. To update Package.swift, we use trusty old sed to rewrite the relevant lines in-place.

For your package to be usable by others, you'll need to host your XCFramework for download somewhere and update Package.swift with an archive checksum and git tag. We use GitHub's release artifact hosting since it's easy and free, but you can also self-host the archive.

Wrap-up

Packaging a binary framework for iOS isn't easy, and the best practices have evolved in the last few years. Which is probably why no comprehensive (modern) guide exists for our use case! We hope this helps anyone else shipping binary frameworks on iOS. We've been using this process for about a year and a half for Ferrostar, and have automated all the steps with shell scripts and CI workflows.

If you're curious to try this in your own project, check out the UniFFI starter, which includes ready-to-go build scripts and all the rest of the project boilerplate. And for a "real-world" CI pipeline on GitHub Actions, check out the iOS Release action for Ferrostar.

In the next installment, we'll cover the build process and packaging for Android. Give us a follow on social media, join our Slack or Discord communities, or subscribe to our mailing list to get the news first!