Rust on iOS and Mac Catalyst: A Simple, Updated Guide

2022-02-11

I write Piccolo, an Othello app for iPhone, iPad and (via Mac Catalyst) macOS. Othello is a well-known Japanese board game, and in order for Piccolo to ship as a full-featured application, an Othello AI must be provided as a necessary component.

Othello AIs, like chess AIs and many other AIs, depend on evaluation functions to look four, five, and in some cases (such as, for example, when up against demanding human players) eight or nine moves ahead, analyzing many board configurations in order to determine the best move. This implies the need to evaluate up to hundreds of thousands of different boards per turn, which implies the need for a performant AI core.

In 2022, native iOS and macOS applications are written in Swift. However, Swift is not a language that lends itself to the programming of highly optimized and performant AIs. The reasons for this involve a discussion that is beyond the scope of this post, but I’ll briefly mention that at time of writing, Swift does not even include support for fixed-length arrays.

As a result, I eventually deemed it necessary to write Piccolo’s Othello AI in Rust, which does lend itself to fast, performant software with a minimal footprint, and to integrate the resulting Rust component into the Piccolo iOS and macOS app, which is written in Swift.

Note: this tutorial is based upon Emil Sjölander’s excellent Rust on iOS tutorial. It deviates from it based on what worked best from me, includes corrections, and expands it to discuss how to achieve Mac Catalyst support, and to how to get your iOS app and Rust library compiling for the App Store (which unfortunately requires LLVM hacking to work properly).

Installing the necessary tools

We assume that you are using rustup, the standard installer for the Rust toolchain.

Once rustup is ready, it is important that you first ensure that we are using Rust Nightly, because compiling the Rust standard library locally will be necessary for Mac Catalyst support, and this cannot be done except under Nightly. So, using rustup:

rustup update
rustup toolchain install nightly
rustup default nightly

We will install support for the following compilation targets:

rustup target add aarch64-apple-ios

In the above, aarch64-apple-ios refers to iOS. I needed to also target Mac Catalyst, but when I typed rustup target add aarch64-apple-ios-macabi x86_64-apple-ios-macabi, I got the following error:

error: toolchain 'nightly-aarch64-apple-darwin' does not contain component 'rust-std' for target 'aarch64-apple-ios-macabi'
note: not all platforms have the standard library pre-compiled: https://doc.rust-lang.org/nightly/rustc/platform-support.html
help: consider using `cargo build -Z build-std` instead

The above indicated to me that I would need to locally compile the Rust standard library for aarch64-apple-ios-macabi (which refers to Mac Catalyst for Apple Silicon Macs) and x86_64-apple-ios-macabi (which refers to Mac Catalyst for Intel Macs). We will need to compile for both Mac Catalyst architectures in order to be able to produce “Universal binaries”, which, in Apple parlance, are binaries that can run on both Apple Silicon and Intel Macs without needing the Rosetta translation layer. I will explain later how to target these architectures.

Creating a simple Rust project

Our software is written in Swift, with Rust being restricted to a single component of it. We want to create a Rust library which will be linked into our Swift-based iOS and Mac software. We do this with cargo:

cargo new my-rust-library --lib

First, let’s open the resulting Cargo.toml and add the following line under the [lib] section:

crate-type = ["staticlib"]

I optionally recommend adding the following compile-time optimizations, which result in much faster Rust code. This was important for me, since performance was my main reason for writing a subcomponent of my Othello software in Rust (with thanks to Georgio Nicolas for pointing me to the lto = fat directive):

[profile.release]
lto = "fat"
opt-level = 3
codegen-units = 1

Let’s open the resulting lib.rs (which will be the “main” file for our library’s code) and replace it with the following code (taken from Emil Sjölander):

use std::os::raw::{c_char};
use std::ffi::{CString, CStr};

#[no_mangle]
pub extern fn rust_hello(to: *const c_char) -> *mut c_char {
    let c_str = unsafe { CStr::from_ptr(to) };
    let recipient = match c_str.to_str() {
        Err(_) => "there",
        Ok(string) => string,
    };
    CString::new("Hello ".to_owned() + recipient).unwrap().into_raw()
}

#[no_mangle]
pub extern fn rust_hello_free(s: *mut c_char) {
    unsafe {
        if s.is_null() { return }
        CString::from_raw(s)
    };
}

In the above:

  1. The use lines are loading libraries that allow us to pass strings to C bindings, because our Swift code will be binding to our Rust library via C bindings.
  2. The #[no_mangle] function attribute instructs the Rust compiler not to “mangle” (i.e. modify) the name of the functions at compile them, eliminating potential linking issues as the library is bound to the larger Swift software.
  3. The rust_hello function takes in a pointer to a C string and returns that string appended to a “Hello” greeting. Notice how careful we are being with the string parsing within that function: that is because Rust’s safety guarantees cannot apply on a C string pointer. We therefore have to retrieve the string from the pointer unsafely, check for errors, and then finally create a new C string which we communicate back as a pointer to the Swift code.
  4. rust_hello_free is a function that must then be mandatorily called to safely de-allocate the string returned by rust_hello:

After linking our library (which I’ll describe below), the above code will be called like this:

let result = rust_hello("world")
let swift_result = String(cString: result!)
rust_hello_free(UnsafeMutablePointer(mutating: result))
print(swift_result)

Generating Rust library bindings for Swift

In his post, Emil Sjölander recommends using cbindgen to then generate the C header bindings for linking our Rust library to Swift. I somewhat do not recommend using cbindgen, because I’ve found that cbindgen sometimes produces headers that are not friendly for C bindings in the context of Swift code. For example, the following function takes in an array of 64 signed integers, followed by one unsigned integer:

#[no_mangle]
pub extern "C" fn example_a(p0: &[isize; 64], p1: usize) -> *mut c_char {
	// Code omitted.
}

cbindgen will produce the following header:

char *example_a(const intptr_t (*p0)[64], uintptr_t p1);

However, Swift does not support fixed-length arrays, and you’ll therefore need to edit the header manually to this:

char *example_a(const intptr_t (*p0), uintptr_t p1);

…while making sure not to overflow the array accessor in both your Swift and Rust code. That being said, cbindgen could be useful for generating a rust header template, and it can be used like this:

cargo install cbindgen
mkdir includes
cbindgen src/lib.rs -l c > includes/lib_headers.h

You may need to double-check the resulting headers afterwards. Fixing the headers manually (as well as writing them from scratch) will likely require moderate knowledge of C (which I barely have) and some thinking on how to best bridge together the inputs/outputs between your Rust library and Swift codebase — good luck, I suppose!

Building your Rust library for iOS and Mac Catalyst

Emil Sjölander’s original article recommends that we compile using cargo-lipo. I strongly do not recommend using cargo-lipo, for the following reasons:

  1. cargo-lipo is deprecated and no longer necessary for iOS.
  2. cargo-lipo does not support Mac Catalyst and will bloat your build setup for no gain.

We will instead create our own build script, or Makefile. First, let’s create an output directory for our compiled libraries (next to Cargo.toml):

mkdir libs

Now, let’s save the following to a file called Makefile within your Rust library directory (next to Cargo.toml and libs):

macos:
	@cargo +nightly build -Z build-std --release --lib --target aarch64-apple-ios-macabi
	@cargo +nightly build -Z build-std --release --lib --target x86_64-apple-ios-macabi
	@$(RM) -rf libs/my-rust-library-maccatalyst.a
	@lipo -create -output libs/my-rust-library-maccatalyst.a \
		target/aarch64-apple-ios-macabi/release/libmy-rust-library.a \
		target/x86_64-apple-ios-macabi/release/libmy-rust-library.a

ios:
	@cargo +ios-arm64-1.57.0 build --release --lib --target aarch64-apple-ios
	@$(RM) -rf libs/my-rust-library-ios.a
	@cp target/aarch64-apple-ios/release/libmy-rust-library.a libs/my-rust-library-ios.a

There are many important things to unpack in the above. First, in the macos section, responsible for building the Mac Catalyst version of the library:

  1. The first line will instruct cargo to build your library for Mac Catalyst for Apple Silicon, by using Rust Nightly in order to build the Rust standard library locally first for Apple Silicon, which is currently necessary to obtain Mac Catalyst Rust builds.
  2. The second line does the same, but for Mac Catalyst builds for Intel Macs.
  3. The third line preemptively deletes any existing Universal library built earlier.
  4. The fourth line uses macOS’s built-in lipo developer command, which Apple designed to allow developers to merge Apple Silicon compiled artifacts and Intel compiled artifact into Universal libraries. Your resulting library, ready for use with Mac Catalyst, will be output into the libs directory! Hurray!

Second, in the ios section, responsible for building the iOS version of the library, we need to pay special attention to the first line: it will instruct cargo to build your library for iOS, using a special Rust toolchain which was created by Ditto Inc. Unfortunately, using this special toolchain is currently necessary due to a known incompatibility issue between Rust’s default toolchains and how Apple uses LLVM to generate BitCode.

BitCode is Apple’s intermediate representation of iOS software, sent when apps are submitted to the App Store in order to allow Apple to automatically recompile your app in the future should there be changes or improvements to Apple’s compilers or supported architectures without involvement on your part.

Here, you have two choices: the easy/bad choice is to disable BitCode (via the Xcode project target build setting) and compile using cargo +nightly instead of cargo +ios-arm64-1.57.0. The good choice is to set up Ditto Inc.’s toolchain, which fixes the issue. Here’s how that’s done:

wget https://github.com/getditto/rust-bitcode/releases/download/v1.57.0/rust-ios-arm64-1.57.0.zip
unzip rust-ios-arm64-1.57.0.zip
cd rust-ios-arm64-1.57.0
sudo xattr -r -d com.apple.quarantine .
./install.sh

In the above:

  1. The first, second and third line download the special BitCode-supporting Rust iOS toolchain, unzip the archive, and cd into the resulting directory.
  2. The fourth line will require your administrative password in order to circumvent security checks on the downloaded files. This is necessary so that macOS will allow you to use the toolchain.
  3. The fifth line installs the toolchain into your user’s~/.rustup/toolchains directory.

Once you’ve taken care of the above, both make macos and make ios commands should work! You’ve now compiled your library for both Mac Catalyst and iOS! With support for universal binaries and BitCode, to boot! You did things right!

Linking your Rust library in Xcode

It’s now time to make sure your Rust library is bound into your Swift project, via Xcode.

First, ensure that the libs directory containing your compiled Rust libraries, and the includes directory containing your C header file, are visible to your Xcode project. Then, open your Swift project in Xcode and:

  1. Click your main project file.
  2. Select your project’s target.
  3. Open the “General” tab.
  4. Scroll down to “Frameworks, Libraries and Embedded Content”.
  5. Add my-rust-library-maccatalyst.a and select “Mac Catalyst” under “Filters”.
  6. Add my-rust-library-ios.a and select “iOS” under “Filters”.
  7. Open the “Build Settings” tab of your project target.
  8. Find the “Header Search Paths” setting and make sure that it includes the path to your includes directory.
  9. Find the “Library Search Paths” setting and make sure that it includes the path to your libs directory.

Now, this Swift code should run from anywhere within your target:

let result = rust_hello("world")
let swift_result = String(cString: result!)
rust_hello_free(UnsafeMutablePointer(mutating: result))
print(swift_result)

That’s right: if you’ve completed all of the above steps correctly, rust_hello and all other functions described within your C header bindings should now be magically accessible throughout your Swift code! As if they’re native functions! Hurray!

Remember to always be careful with passing values to and from Rust, as these values are unsafe C values not covered by any of the security guarantees of either Rust or Swift.

If you find any omissions or errors in this guide, please leave a comment below and I will address them. I hope that it’s been useful to you. Enjoy writing Rust software for Apple platforms!