This Week in Glean: Building and Deploying a Rust library on iOS
(“This Week in Glean” is a series of blog posts that the Glean Team at Mozilla is using to try to communicate better about our work. They could be release notes, documentation, hopes, dreams, or whatever: so long as it is inspired by Glean.) All "This Week in Glean" blog posts are listed in the TWiG index (and on the Mozilla Data blog). This article is cross-posted on the Mozilla Data blog.
We ship the Glean SDK for multiple platforms, one of them being iOS applications. Previously I talked about how we got it to build on the Apple ARM machines. Today we will take a closer look at how to bundle a Rust static library into an iOS application.
The Glean SDK project was set up in 2019 and we have evolved its project configuration over time.
A lot has changed in Xcode since then, so for this article we're starting with a fresh Xcode project, a fresh Rust library and put it all together step by step.
This is essentially an update to the Building and Deploying a Rust library on iOS article from 2017.
For future readers: This was done using Xcode 13.2.1 and rustc 1.58.1.
One note: I learned iOS development to the extent required to ship Glean iOS.
I've never written a full iOS application and lack a lot of experience with Xcode.
The application
The premise of our application is easy:
Show a non-interactive message to the user with data from a Rust library.
Let's get started on that.
The project
We start with a fresh iOS project.
Go to File -> New -> Project, then choose the iOS App template,
give it a name such as ShippingRust
,
select where to store it and finally create it.
You're greeted with ContentView.swift
and the following code:
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
}
}
You can build and run it now. This will open the Simulator and display "Hello, world!". We'll get back to the Swift application later.
The Rust parts
First we set up the Rust library.
In a terminal navigate to your ShippingRust
project directory.
In there create a new Rust crate:
cargo new --lib shipping-rust-ffi
We will need a static library, so we change the crate type in the generated shipping-rust-ffi/Cargo.toml
.
Add the following below the package configuration:
[lib]
crate-type = ["staticlib"]
Let's also turn the project into a Cargo workspace.
Create a new top-level Cargo.toml
with the content:
[workspace]
members = [
"shipping-rust-ffi"
]
cargo build
in the project directory should work now and create a new static library.
$ ls -l target/debug/libshipping_rust_ffi.a
-rw-r--r-- 2 jer staff 16061952 Jan 28 13:09 target/debug/libshipping_rust_ffi.a
Let's add some code to shipping-rust-ffi/src/lib.rs
next.
Nothing fancy, a simple function taking some arguments and returning the sum:
use std::os::raw::c_int;
#[no_mangle]
pub extern "C" fn shipping_rust_addition(a: c_int, b: c_int) -> c_int {
a + b
}
The no_mangle
ensures the name lands in the compiled library as-is
and the extern "C"
makes sure it uses the right ABI.
We now have a Rust library exporting a C-ABI compatible interface. We can now consume this in our iOS application.
The Xcode parts
Before we can use the code we need a bit more setup. Strap in, there's a lot of fiddly manual steps now1.
We start by linking against the libshipping_rust_ffi.a
library.
In your Xcode project open your target configuration2,
go to "Build Phases", then look for "Link Binary with Libraries".
Add a new one, in the popup select "Add files" on the bottom left
and look for the target/debug/libshipping_rust_ffi.a
file.
Yes, that's actually for the wrong target. This is just for the name, we'll fix up the path next.
Go to "Build Settings" and search for "Library Search Paths".
It probably has the path to file in there right now for both Debug
and Release
builds.
Remove that one for Debug
, then add a new row by clicking the small +
symbol.
Select the Any Driverkit
matcher.
It doesn't matter which matcher you choose or what value you give it,
but when we overwrite this manually in the next step I'll assume you chose Any Driverkit
.
Do the same for the Release
configuration.
Once that's done, save your project and go back to your project directory.
We will modify the project configuration to have Xcode look for the library based on the target it is building for3.
Open up ShippingRust.xcodeproj/project.pbxproj
in a text editor,
then search for the first line with "LIBRARY_SEARCH_PATHS[sdk=driverkit*]"
.
It should be in a section saying /* Debug */
.
Remove the LIBRARY_SEARCH_PATHS
line and add 3 new ones:
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*][arch=arm64]" = "$(PROJECT_DIR)/target/aarch64-apple-ios/debug";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=arm64]" = "$(PROJECT_DIR)/target/aarch64-apple-ios-sim/debug";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=x86_64]" = "$(PROJECT_DIR)/target/x86_64-apple-ios/debug";
Look for the next line with "LIBRARY_SEARCH_PATHS[sdk=driverkit*]"
, now in a /* Release */
section and replace it with:
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*][arch=arm64]" = "$(PROJECT_DIR)/target/aarch64-apple-ios/release";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=arm64]" = "$(PROJECT_DIR)/target/aarch64-apple-ios-sim/release";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=x86_64]" = "$(PROJECT_DIR)/target/x86_64-apple-ios/release";
Save the file and return focus back to Xcode. If you didn't make any typos Xcode should still have your project open. In the settings you will find the library search paths as we've just defined them. If you messed something up Xcode will complain that it cannot read the project file if you try to go to the settings.
Next we need to teach Xcode how to compile Rust code. Once again go to your target settings, selecting the "Build Phases" tab again.
There add a new "Run Script" phase, give it the name "Build Rust library" (double-click the "Run Script" section header), and set the command to:
bash ${PROJECT_DIR}/bin/compile-library.sh shipping-rust-ffi $buildvariant
The compile-library.sh
script is going to do the heavy lifting.
The first argument is the crate name we want to compile, the second is the build variant to select.
This is not yet defined, so let's do it first.
Go to the "Build Settings" tab and click the +
button to add a new "User-Defined Setting".
Give it the name buildvariant
and choose a value based on the build variant: debug
for Debug
and release
for Release
.
Now we need the actual script to build the Rust library for the right targets.
It's a bit long to write out, but the logic is not too complex:
First we select the Cargo profile to use based on our buildvariant
(that is whether to pass --release
or not),
then we set up LIBRARY_PATH
if necessary and finally compile the Rust library for the selected target.
Xcode passes the architectures to build in ARCHS
.
It's either x86_64
for simulator builds on Intel Mac hardware or arm64
.
If it's arm64
it can be either the simulator or an actual hardware target.
Those differ, but we can know which is which from what's in LLVM_TARGET_TRIPLE_SUFFIX
and select the right Rust target.
Let's put all of that into a compile-library.sh
script.
Create a new directory bin
in your project directory.
In there create the file with the following content:
#!/usr/bin/env bash
if [ "$#" -ne 2 ]
then
echo "Usage (note: only call inside xcode!):"
echo "compile-library.sh <FFI_TARGET> <buildvariant>"
exit 1
fi
# what to pass to cargo build -p, e.g. your_lib_ffi
FFI_TARGET=$1
# buildvariant from our xcconfigs
BUILDVARIANT=$2
RELFLAG=
if [[ "$BUILDVARIANT" != "debug" ]]; then
RELFLAG=--release
fi
set -euvx
if [[ -n "${DEVELOPER_SDK_DIR:-}" ]]; then
# Assume we're in Xcode, which means we're probably cross-compiling.
# In this case, we need to add an extra library search path for build scripts and proc-macros,
# which run on the host instead of the target.
# (macOS Big Sur does not have linkable libraries in /usr/lib/.)
export LIBRARY_PATH="${DEVELOPER_SDK_DIR}/MacOSX.sdk/usr/lib:${LIBRARY_PATH:-}"
fi
IS_SIMULATOR=0
if [ "${LLVM_TARGET_TRIPLE_SUFFIX-}" = "-simulator" ]; then
IS_SIMULATOR=1
fi
for arch in $ARCHS; do
case "$arch" in
x86_64)
if [ $IS_SIMULATOR -eq 0 ]; then
echo "Building for x86_64, but not a simulator build. What's going on?" >&2
exit 2
fi
# Intel iOS simulator
export CFLAGS_x86_64_apple_ios="-target x86_64-apple-ios"
$HOME/.cargo/bin/cargo build -p $FFI_TARGET --lib $RELFLAG --target x86_64-apple-ios
;;
arm64)
if [ $IS_SIMULATOR -eq 0 ]; then
# Hardware iOS targets
$HOME/.cargo/bin/cargo build -p $FFI_TARGET --lib $RELFLAG --target aarch64-apple-ios
else
$HOME/.cargo/bin/cargo build -p $FFI_TARGET --lib $RELFLAG --target aarch64-apple-ios-sim
fi
esac
done
And now we're done with the setup for compiling the Rust library automatically as part of the Xcode project build.
The code parts
We now have an Xcode project that builds our Rust library and links against it. We now need to use this library!
Swift speaks Objective-C, which is an extension to C,
but we need to tell it about the things available.
In C land that's done with a header.
Let's create a new file, select the "Header File" template and name it FfiBridge.h
.
This will create a new file with this content:
#ifndef FfiBridge_h
#define FfiBridge_h
#endif /* FfiBridge_h */
Here we need to add the definition of our function. As a reminder this is its definition in Rust:
extern "C" fn shipping_rust_addition(a: c_int, b: c_int) -> c_int;
This translates to the following in C:
int shipping_rust_addition(int a, int b);
Add that line between the #define
and #endif
lines.
Xcode doesn't know about that file yet, so once more into the Build Settings
of the target.
Search for Objective-C Bridging Header
and set it to $(PROJECT_DIR)/ShippingRust/FfiBridge.h
.
In Build Phases
add a new Header Phase
.
There you add the FfiBridge.h
as well.
If it now all compiles we're finally ready to use our Rust library.
Open up ContentView.swift
and change the code to call your Rust library:
struct ContentView: View {
var body: some View {
Text("Hello, world! \(shipping_rust_addition(30, 1))")
.padding()
}
}
We simply interpolate the result of shipping_rust_addition(30, 1)
into the string displayed.
Once we compile and run it in the simulator we see we've succeeded at satisfying our premise:
Show a non-interactive message to the user with data from a Rust library.
Compiling for any iOS device should work just as well.
The next steps
This was a lot of setup for calling one simple function. Luckily this is a one-time setup. From here on you can extend your Rust library, define them in the header file and call them from Swift. If you go that route you should really start using cbindgen to generate that header file automatically for you.
This time we looked at building an iOS application directly calling a Rust library. That's not actually how Glean works. The Glean Swift SDK itself wraps the Rust library and exposes a Swift library. In a next blog post I'll showcase how we ship that as a Swift package.
For Glean we're stepping away from manually writing our FFI functions. We're instead migrating our code base to use UniFFI. UniFFI will generate the C API from an API definitions file and also comes with a bit of runtime code to handle conversion between Rust, C and Swift data types for us. We're not there yet for Glean, but you can try it on your own. Read the UniFFI documentation and integrate it into your project. It should be possible to extent the setup we done her to also run the necessary steps for UniFFI. Eventually I'll document how we did it as well.
Footnotes:
And most of these steps are user-interface-dependent and might be different in future Xcode version. :(
2: Click your project name in the tree view on the left. This gets you to the project configuration (backed by the ShippingRust.xcodeproj/project.pbxproj
file). You should then see the Targets, including your ShippingRust
target and probably ShippingRustTests
as well. We need the former.
3: Previously we would have built a universal library containing the library of multiple targets. That doesn't work anymore now that arm64
can stand for both the simulator and hardware targets. Thus linking to the individual libraries is the way to go, as the now-deprecated cargo-lipo
also points out.