— 8 min read
Embed a Rust library in Xcode
A guide to using Rust libraries in iOS/macOS projects.
For this blog post I’ll describe in detail what I find to be the easiest way to embed a Rust library in iOS/macOS.
As of this writing the software needed is Xcode 15.1, Rust version 1.75, the various toolchains for arm64 and x86_64 (all via Rustup) and a tiny crate called cargo-xcode version 1.9.0.
But why?
Rust is an amazing language for safe and high performing applications, and it is one of the most promising languages developed during the last decade.
The ecosystem is mature and it’s an ideal choice if you want to develop cross-platform apps. And even though Rust compiles to native machine code for Apple platforms, the SDKs does not natively understand Rust.
But that’s not an issue, since a lot of languages doesn’t offer a direct integration with Swift. C++ is among those languages who doesn’t offer a native bridge to Swift. What we’re going to do to solve this issue, is using what is known as FFI via the C ABI.
But don’t worry. We’re not going to write C in the classical sense, we’re using C as the common denominator between Rust and Swift, since both of these languages understand C.
FFI stands for Foreign Function Interface and is a way for many languages to interact with a different language. ABI stands for Application Binary Interface (not to be confused with API which stands for Application Programming Interface) which gives us a way to define how the communication between the different code bases is done.
Prerequisites
If you haven’t already, install Xcode from the App Store. Once installed, open it and accept the license agreement. Make sure that this Xcode version is selected as the Command Line Tools in the Xcode preferences under the Locations tab.
Next up is Rust which is installed via Rustup. Run the following and use the default settings:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Once installed, open your preferred terminal and run the following command:
rustup target add aarch64-apple-darwin x86_64-apple-darwin aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
This will install the necessary toolchains for Rust to compile for Apple Silicon and Intel processors, as well as the necessary toolchains for iOS and the iOS simulator.
The final piece of the puzzle is the cargo-xcode crate. To install this crate, run the following command:
cargo install cargo-xcode
Let’s get started
We start by opening Xcode and create a new iOS project. In this example we’ll call the project “Ferrum”. When the project is created, navigate to the top-level overview, and remove the macOS target (“Designed for iPad”) and replace it with the AppKit flavor instead. This will create a macOS app with a fat binary for both Apple Silicon and Intel processors.
Next up is our Rust library. Bring up your preferred terminal and navigate to the Ferrum project. Once inside run the following command:
cargo new --lib oxide
We’ve now created a brand new library crate (via the —lib flag) inside our Ferrum project. Navigate inside the oxide library with the terminal and open it in your editor of choice. I’m using Visual Studio Code, but any editor will do. Open the Cargo.toml
file and after the last package related setting add the following below it:
[lib]
crate-type = [ "lib", "staticlib" ]
Add first glance one of these two tags might seem redundant, but they are both equally important. The staticlib
is telling the compiler to compile everything inside a static library, which is the preferred way to embed things in iOS/macOS development. When compiling a static library everything is included and linked ahead of time (AOT), which ensures that everything is blazing fast and gives the best performance. The main downside of this approach is that the library binary will result in an over-all larger app size. Luckily this is not an issue in our case, but it is worth to have in mind.
The lib
is necessary to ensure that tests from external crates included in our own works as expected, since the staticlib
tag is rarely used when creating libraries in Rust.
With that out of the way, we can open our lib.rs
in the src
folder. Start by deleting all the code in this file and replace it with the following:
use std::ffi::c_int;
#[no_mangle]
pub extern "C" fn simple_addition(a: c_int, b: c_int) -> c_int {
a + b
}
This is not advanced code by any means, but it illustrates the approach well. Rust ships with a capable FFI module in the standard library, which is where we are getting our C integer type from. By using this type, we ensure that both the input and output is compatible with C and everything else that knows C, and in most cases the C integer is equal to 32-bit signed integer.
The pub extern "C"
tells the compiler that we are making a public function meant for interfacing with C, which is necessary for all code we want to interface with via ABI. The #[no_mangle]
is equally important, and without it our Xcode project will fail to compile later on. This is due to the compiler by default will compile our code into the most optimized form possible (which is not unique to Rust, this is used everywhere), sacrificing they layout of our function for external readers. By using this setting, we are telling the compiler that it might not be the most optimized approach, but that’s fine because it’s a requirement that the function looks like this. For now we’ll close down our Rust project and turn our attention back to the terminal.
While inside the oxide library run the following command:
cargo xcode
A new Xcode project will now be generated. Do not open the project, instead open the Ferrum project and drag the newly generated project inside it. Our initial Ferrum project now treats the oxide project as a subproject, which is the clever part about all this. Now we can compile everything from our main project directly in Xcode!
We just need to make a small adjustment to the newly created oxide project, by doubling clicking it and going to the projects settings. In there remove the macosx
entity in the Additional SDKs
.
(Full disclosure: I have no idea why this step is needed, but I’ve discovered that if I keep the arguments, it will fail to compile when I call the code later on from Swift.)
Navigate back to the main project settings and go into Build Phases
and click the Link Binaries With Libraries
. Click the +
and at the top select the liboxide.a
which is the library generated from Rust. Notice how Xcode automatically understands that this is a library we can use directly in our project. This will come in handy later on as the project expands, because every time we change any of the code in either project, Xcode will automatically ensure that we’re using the latest compiled versions and if these are not available, compile them for us without the need to touch the terminal.
But we’re missing a key part of all this, because even though our projects is communicating our code is not. We can however change this by using a header file. In Xcode we create a new header file with the name FfiBridge.h
and place it in the root of our project. This header file is where our C code is coming into play. Open the newly created file and write the C equivalent to our Rust function created earlier:
#ifndef FfiBridge_h
#define FfiBridge_h
int simple_addition(int a, int b);
#endif /* FfiBridge_h */
This piece of code is mirroring the public facing code in our Rust function, taking two integers and returning the sum of these. Go back to the Build Phases
and click the +
to add a new build phase. Select New Headers Phase
and drag the FfiBridge.h
file into the phase. We’re almost done with the linking of our headers, and for the final step navigate to the Build Settings
and ensure that visibility is set to All
. It is important to know that technically Swift projects doesn’t natively support C, but luckily Objective-C is a true superset of C, which means that every C code is perfectly valid Objective-C code. Therefore go to the search field, search for Objective-C
and find the field named Objective-C Bridging Header
. Write the dynamic path to the header file in this field: ${PROJECT_DIR}/FfiBridge.h
. You’ll notice that once we’re done editing the dynamic path, it’ll automatically translate and display the current path it’s placed in. Everything is linked and setup to be used directly from Swift now!
Go to the ContentView.Swift
file in the Ferrum project and replace the text displaying hello world with our simple_addition
function making the code look like this:
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("\(simple_addition(5, 3))")
}
.padding()
}
}
#Preview {
ContentView()
}
And voila! The preview now shows the sum of our two integers, which means our Swift code is communicating with the Rust library as intended.
This is a very basic example of how to use Rust in a Swift project, but it’s a good starting point. You can find the repository right here and use it as a template for your own project. Happy coding!