crust / Part 12 - iOS

Rusty boat: attribution https://www.pexels.com/photo/a-rusty-boat-on-the-seashore-9816335/

In this article we will create a new Xcode project and implement the crust-build pipeline for building the iOS target platform. This will let us run crust on an iPhone - such that it could also be published to the Apple App Store - though you would need an active Apple Developer subscription to do that. We will:


Xcode project

In the previous article we implemented the MacOS Desktop target - for iOS we will take a very similar approach in that the Xcode project will invoke crust-build through a shell script build step to compile and prepare the Rust dependencies.

To start off, create a new ios directory in your workspace:

+ root
    + android
    + crust-build
    + crust-main
    + ios
    + macos-desktop

Then open Xcode and create a new iOS app project:

Fill in the details as below - again noting that we don’t need Swift support as we will write barely any Apple code:

Select your ios directory to save the project into. After completion it should look like this:

In the Deployment Info section, change the following settings:

Next we will delete redundant source files that our app won’t need. Delete the following files (choose Move to Trash):

Your project should now look like this:

The directory structure should be like:

+ root
    ...
    + ios
        + crust
            + crust
            + crust.xcodeproj

Default PLIST

We need to tidy a few things in the Info.plist file as well, open it and delete the Scene configuration node as it contains references to some of the source files we just removed.

Also delete the two portrait related properties for iPad support - we only want crust to run in landscape regardless of phone or tablet:

Invoke shell script

Next we will add the shell script build step which will invoke crust-build for the iOS target. Navigate to the Build Phases section and add a new script step:

Move the script step as far up the list as possible - just under the Dependencies step, disable the Based on dependency analysis checkbox and enter the following script:

set -e
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:$HOME/.cargo/bin"
cd "$SRCROOT/../../crust-build"
cargo run -- --target ios --variant $CONFIGURATION

This is exactly the same as what we did for the MacOS Desktop article, except we are specifying ios for crust-build instead of macos-desktop.

You can run the Xcode project now and observe in the Report navigator that our crust-build application ran. You will have build errors but we’ll fix that soon enough.

Rust build implementation

The Rust code to build iOS is a bit more complicated than for the MacOS targets because we can’t use SDL as frameworks - rather we need them to be static libraries.

We also have an additional complication that with the introduction of Apple Silicon Mac computers, we have to differentiate between arm64 simulators versus arm64 devices - historically there was an assumption that building for arm64 means a phsyical phone which is no longer the case. We will need to build our Rust code for 3 targets:

We also need to combine the two simulator architectures together as they are not regarded as discrete platforms in the Xcode tooling.

The build flow for iOS is:

I’ll step through this flow a bit more methodically than we did for MacOS Desktop as some of the code needs a bit of explaining as we go.

Rust toolchains

Edit crust-build/src/ios.rs and add the following constants:

const ARCHITECTURE_ARM64_IPHONE: &str = "aarch64-apple-ios";
const ARCHITECTURE_ARM64_SIMULATOR: &str = "aarch64-apple-ios-sim";
const ARCHITECTURE_X86_64_SIMULATOR: &str = "x86_64-apple-ios";

Then add a new method to install the required Rust toolchains - there isn’t anything here you haven’t seen before:

fn install_rust_dependencies() -> FailableUnit {
    scripts::run(&Script::new(&format!(
        "rustup target add {} {} {}",
        ARCHITECTURE_ARM64_IPHONE, ARCHITECTURE_ARM64_SIMULATOR, ARCHITECTURE_X86_64_SIMULATOR
    )))
}

Update the build method:

pub fn build(context: &Context) -> FailableUnit {
    context.print_summary();

    install_rust_dependencies()?;
    
    Ok(())
}

Setup frameworks directory

Add the following method to create a new Frameworks working directory where our custom xcframeworks will be published to, along with a symlink inside the Xcode project directory structure:

fn setup_frameworks_dir(context: &Context) -> Failable<PathBuf> {
    let frameworks_dir = context.working_dir.join("Frameworks");
    io::create_dir(&frameworks_dir)?;
    io::create_symlink(&frameworks_dir, &context.target_home_dir.join("crust").join("Frameworks"), &context.working_dir)?;

    Ok(frameworks_dir)
}

Update build to call this method and hold onto the resulting directory path:

pub fn build(context: &Context) -> FailableUnit {
    context.print_summary();

    install_rust_dependencies()?;

    let frameworks_dir = setup_frameworks_dir(context)?;

    Ok(())
}

Setup SDL2

Ok this is where things start to get a bit more involved. We will need to download the SDL2 source code then build the static library Xcode project within it for all three of our target architectures. The SDL2 source code doesn’t currently (at the time of writing this) have an xcframework output target of its own, so we need to manufacture our own xcframework once all the architectures are built.

Add the following constants:

const SDL2_URL: &str = "https://www.libsdl.org/release/SDL2-2.0.14.zip";
const SDL2_DIR: &str = "SDL";
const SDL2_FRAMEWORK_NAME: &str = "SDL2.xcframework";

The SDL2_FRAMEWORK_NAME constant is the name of the xcframework we will manufacture with the outputs of the compiled source code.

Note: The directory where for SDL2 source code must be SDL so libraries such as SDL2_image can automatically locate it.

Add the following method:

fn setup_sdl2(context: &Context, frameworks_dir: &PathBuf) -> FailableUnit {
    let xcframework_path = frameworks_dir.join(SDL2_FRAMEWORK_NAME);

    if xcframework_path.exists() {
        return Ok(());
    }

    // The source code for SDL2 needs to be available for us to build the framework.
    remote_zips::fetch(SDL2_URL, SDL2_DIR, &context.working_dir)?;

    // This is the directory where the SDL2 Xcode project can be found which when compiled produces the static libraries we need.
    let xcode_project_dir = context.working_dir.join(SDL2_DIR).join("Xcode").join("SDL");

    // We need to build the static library for both simulators (ARM64 + x86_64). Note also that we are deliberately building the Release variant only.
    logs::out(log_tag!(), "Compiling SDL2 simulator release static library ...");
    scripts::run(&Script::new(
        r#"xcodebuild -project "SDL.xcodeproj" -scheme "Static Library-iOS" -configuration Release -sdk iphonesimulator -derivedDataPath "build_iphonesimulator" -arch arm64 -arch x86_64 only_active_arch=no"#
    ).working_dir(&xcode_project_dir))?;

    // We now need to build the static library for the phone target (ARM64).
    logs::out(log_tag!(), "Compiling SDL2 phone release static library ...");
    scripts::run(&Script::new(
        r#"xcodebuild -project "SDL.xcodeproj" -scheme "Static Library-iOS" -configuration Release -sdk iphoneos -derivedDataPath "build_iphoneos" -arch arm64 only_active_arch=no"#
    ).working_dir(&xcode_project_dir))?;

    // Once both the simulators + phone static libraries have been built, we need to merge them together into an XCFramework which will be embedded in the iOS project.
    let headers_path = context.working_dir.join(SDL2_DIR).join("include");
    logs::out(log_tag!(), "Creating SDL2 XCFramework ...");
    scripts::run(&Script::new(&format!(
        r#"xcodebuild -create-xcframework -library build_iphonesimulator/Build/Products/Release-iphonesimulator/libSDL2.a -headers {:?} -library build_iphoneos/Build/Products/Release-iphoneos/libSDL2.a -headers {:?} -output {:?}"#,
        &headers_path,
        &headers_path,
        &xcframework_path
    )).working_dir(&xcode_project_dir))?;

    Ok(())
}

I have added code comments to describe what each part of the method does, but in essence we are compiling the SDL2 source code for both the iphonesimulator and iphoneos platforms, specifying explicitly the -derivedDataPath so we know where the build outputs land.

Then we use the Xcode -create-xcframework command line tool to generate our custom SDL2.xcframework, pulling into it the libSDL2.a outputs from the build_iphonesimulator and build_iphoneos derived data paths.

The result is an SDL2 xcframework that contains the compiled static library artifacts to support iOS simulators and phones. We want to use the xcframework approach to let Xcode build against the appropriate platform architecture automatically.

Update the build method to run this:

pub fn build(context: &Context) -> FailableUnit {
    context.print_summary();

    install_rust_dependencies()?;

    let frameworks_dir = setup_frameworks_dir(context)?;
    
    setup_sdl2(context, &frameworks_dir)?;
 
    Ok(())
}

Setup SDL2 Image

The setup for SDL2 Image is very similar to SDL2. First add some constants:

const SDL2_IMAGE_URL: &str = "https://www.libsdl.org/projects/SDL_image/release/SDL2_image-2.0.5.zip";
const SDL2_IMAGE_DIR: &str = "SDL2_image";
const SDL2_IMAGE_FRAMEWORK_NAME: &str = "SDL2_image.xcframework";

Then add a new method to prepare the SDL2 Image libraries:

fn setup_sdl2_image(context: &Context, frameworks_dir: &PathBuf) -> FailableUnit {
    let xcframework_path = frameworks_dir.join(SDL2_IMAGE_FRAMEWORK_NAME);

    // No need to build if its output already exist.
    if xcframework_path.exists() {
        return Ok(());
    }

    // The source code for SDL2 image needs to be available for us to build the framework.
    remote_zips::fetch(SDL2_IMAGE_URL, SDL2_IMAGE_DIR, &context.working_dir)?;

    // // This is the directory where the SDL2 Image Xcode project can be found which when compiled produces the static libraries we need.
    let xcode_project_dir = context.working_dir.join(SDL2_IMAGE_DIR).join("Xcode-iOS");

    // We need to build the static library for both simulators (ARM64 + x86_64). Note also that we are deliberately building the Release variant only.
    logs::out(log_tag!(), "Compiling SDL2 Image simulator release static library ...");
    scripts::run(&Script::new(
        r#"xcodebuild -project "SDL_image.xcodeproj" -scheme "libSDL_image-iOS" -configuration Release -sdk iphonesimulator -derivedDataPath "build_iphonesimulator" -arch arm64 -arch x86_64 only_active_arch=no"#
    ).working_dir(&xcode_project_dir))?;

    // We now need to build the static library for the phone target (ARM64).
    logs::out(log_tag!(), "Compiling SDL2 Image phone release static library ...");
    scripts::run(&Script::new(
        r#"xcodebuild -project "SDL_image.xcodeproj" -scheme "libSDL_image-iOS" -configuration Release -sdk iphoneos -derivedDataPath "build_iphoneos" -arch arm64 only_active_arch=no"#
    ).working_dir(&xcode_project_dir))?;

    // Once both the simulators + phone static libraries have been built, we need to merge them together into an XCFramework which will be embedded in the iOS project.
    // Note: We do not need to include the headers in the framework as we won't be accessing them in our iOS project directly.
    logs::out(log_tag!(), "Creating SDL2 Image XCFramework ...");
    scripts::run(&Script::new(&format!(
        r#"xcodebuild -create-xcframework -library build_iphonesimulator/Build/Products/Release-iphonesimulator/libSDL2_image.a -library build_iphoneos/Build/Products/Release-iphoneos/libSDL2_image.a -output {:?}"#,
    &xcframework_path)).working_dir(&xcode_project_dir))?;

    Ok(())
}

Although the code is very similar to the SDL2 library, I haven’t tried to be too clever and extract out the common parts, preferring a bit of duplication to keep the code easier to read - fundamentally though we are doing the same thing.

Update the build method to run this:

pub fn build(context: &Context) -> FailableUnit {
    context.print_summary();

    install_rust_dependencies()?;

    let frameworks_dir = setup_frameworks_dir(context)?;
    
    setup_sdl2(context, &frameworks_dir)?;
    setup_sdl2_image(context, &frameworks_dir)?;
 
    Ok(())
}

Update crate type for build

Next up we need to use our manifests service that we wrote in the Android article to create a copy of the main Cargo.toml but replace the crate-type property to staticlib. If we don’t do this, our own Rust code will not be compiled in a way that we can link as a static library in Xcode.

Update the build method to create a manifest with staticlib as the crate-type via the manifests::create invocation:

pub fn build(context: &Context) -> FailableUnit {
    context.print_summary();

    install_rust_dependencies()?;

    let frameworks_dir = setup_frameworks_dir(context)?;
    
    setup_sdl2(context, &frameworks_dir)?;
    setup_sdl2_image(context, &frameworks_dir)?;
    
    manifests::create(context, "staticlib")?;

    Ok(())
}

Compile Rust code

We can now compile our Rust code, keeping in mind that we need to build the library Rust target rather than the binary target. We will loop through each of the three architectures and perform the appropriate Rust compilation command:

Note: debug builds will produce WAY bigger output files compared to release builds which are much more compact.

fn compile(context: &Context) -> FailableUnit {
    for architecture in &vec![
        ARCHITECTURE_ARM64_IPHONE,
        ARCHITECTURE_ARM64_SIMULATOR,
        ARCHITECTURE_X86_64_SIMULATOR,
    ] {
        logs::out(log_tag!(), &format!("Compiling crust for architecture: {}", &architecture));
        scripts::run(
            &Script::new(&format!(
                "cargo rustc {} --target-dir {:?} --lib --target {}",
                context.variant.rust_compiler_flag(),
                context.rust_build_dir,
                architecture,
            ))
            .working_dir(&context.working_dir),
        )?;
    }

    Ok(())
}

The Rust compilation command is fairly straight forward - we just tell it to target each of our iOS architectures one at a time. Update build to run the compilation:

pub fn build(context: &Context) -> FailableUnit {
    context.print_summary();

    install_rust_dependencies()?;

    let frameworks_dir = setup_frameworks_dir(context)?;
    setup_sdl2(context, &frameworks_dir)?;
    setup_sdl2_image(context, &frameworks_dir)?;
    manifests::create(context, "staticlib")?;
    compile(context)?;

    Ok(())
}

Create output

The final part of the Rust implementation is to collect the compiled Rust code artifacts and merge them into a single static library xcframework that our Xcode project can consume. I have added comments in the code to describe what we are doing:

fn create_output(context: &Context, frameworks_dir: &PathBuf) -> FailableUnit {
    let variant_dir = context.variant.id();

    // Now that each of the architectures has been compiled, we need to generate an XCFramework which merges them all together for the iOS project.
    // When generating an XCFramework we must actually join any compiled architectures that belong to the same family together first - in our case
    // we have compiled 2 iOS simulator architectures (ARM64 + x86_64). If we were to try and make an XCFramework where each of the simulator
    // architectures were bundled separately, the XCFramework command would fail so we must first join them into 1 static library. To join compiled
    // architectures together we use the 'lipo' tool.
    logs::out(log_tag!(), "Joining iOS simulator architectures together ...");
    let simulator_static_library_dir = context
        .rust_build_dir
        .join(&format!("{}-{}", ARCHITECTURE_ARM64_SIMULATOR, ARCHITECTURE_X86_64_SIMULATOR))
        .join(variant_dir);

    // Make sure the path to our merged static library exists.
    io::create_dir(&simulator_static_library_dir)?;

    let simulator_static_library_path = simulator_static_library_dir.join("libcrustlib.a");

    scripts::run(
        &Script::new(&format!(
            "lipo -create -output {:?} {:?} {:?}",
            &simulator_static_library_path,
            &context.rust_build_dir.join(ARCHITECTURE_ARM64_SIMULATOR).join(variant_dir).join("libcrustlib.a"),
            &context.rust_build_dir.join(ARCHITECTURE_X86_64_SIMULATOR).join(variant_dir).join("libcrustlib.a"),
        ))
        .working_dir(&context.working_dir),
    )?;

    // Now that we have a static library for the iPhone architecture and a static library representing both iOS simulator architectures (via the lipo tool),
    // we can build the XCFramework which wraps it all up and can then be used as a framework dependency in the iOS project.
    let xcframework_path = frameworks_dir.join("crust.xcframework");
    let iphone_static_library_path =
        context.rust_build_dir.join(ARCHITECTURE_ARM64_IPHONE).join(variant_dir).join("libcrustlib.a");

    // Start off by deleting the existing framework if it exists - we should do this so building a debug/release variant always produces the appropriate XCFramework.
    io::delete(&xcframework_path)?;

    // Now run the appropriate Xcode command to create the framework.
    logs::out(log_tag!(), "Creating XCFramework for 'crust' ...");
    scripts::run(
        &Script::new(&format!(
            "xcodebuild -create-xcframework -library {:?} -library {:?} -output {:?}",
            &iphone_static_library_path, &simulator_static_library_path, &xcframework_path,
        ))
        .working_dir(&context.working_dir),
    )?;

    // The Frameworks directory now contains 'crust.xcframework' ready to be used in the iOS Xcode project.
    Ok(())
}

Update the build method to call this too - the full source code for ios.rs will end up like this:

use crate::{
    core::{
        context::Context, failable::Failable, failable_unit::FailableUnit, io, logs, manifests, remote_zips, script::Script,
        scripts,
    },
    log_tag,
};
use std::path::PathBuf;

const ARCHITECTURE_ARM64_IPHONE: &str = "aarch64-apple-ios";
const ARCHITECTURE_ARM64_SIMULATOR: &str = "aarch64-apple-ios-sim";
const ARCHITECTURE_X86_64_SIMULATOR: &str = "x86_64-apple-ios";

const SDL2_URL: &str = "https://www.libsdl.org/release/SDL2-2.0.14.zip";
const SDL2_DIR: &str = "SDL";
const SDL2_FRAMEWORK_NAME: &str = "SDL2.xcframework";

const SDL2_IMAGE_URL: &str = "https://www.libsdl.org/projects/SDL_image/release/SDL2_image-2.0.5.zip";
const SDL2_IMAGE_DIR: &str = "SDL2_image";
const SDL2_IMAGE_FRAMEWORK_NAME: &str = "SDL2_image.xcframework";

pub fn build(context: &Context) -> FailableUnit {
    context.print_summary();

    install_rust_dependencies()?;

    let frameworks_dir = setup_frameworks_dir(context)?;
    setup_sdl2(context, &frameworks_dir)?;
    setup_sdl2_image(context, &frameworks_dir)?;
    manifests::create(context, "staticlib")?;
    compile(context)?;
    create_output(context, &frameworks_dir)?;

    Ok(())
}

fn install_rust_dependencies() -> FailableUnit {
    scripts::run(&Script::new(&format!(
        "rustup target add {} {} {}",
        ARCHITECTURE_ARM64_IPHONE, ARCHITECTURE_ARM64_SIMULATOR, ARCHITECTURE_X86_64_SIMULATOR
    )))
}

fn setup_frameworks_dir(context: &Context) -> Failable<PathBuf> {
    let frameworks_dir = context.working_dir.join("Frameworks");
    io::create_dir(&frameworks_dir)?;
    io::create_symlink(&frameworks_dir, &context.target_home_dir.join("crust").join("Frameworks"), &context.working_dir)?;

    Ok(frameworks_dir)
}

fn setup_sdl2(context: &Context, frameworks_dir: &PathBuf) -> FailableUnit {
    let xcframework_path = frameworks_dir.join(SDL2_FRAMEWORK_NAME);

    if xcframework_path.exists() {
        return Ok(());
    }

    // The source code for SDL2 needs to be available for us to build the framework.
    remote_zips::fetch(SDL2_URL, SDL2_DIR, &context.working_dir)?;

    // This is the directory where the SDL2 Xcode project can be found which when compiled produces the static libraries we need.
    let xcode_project_dir = context.working_dir.join(SDL2_DIR).join("Xcode").join("SDL");

    // We need to build the static library for both simulators (ARM64 + x86_64). Note also that we are deliberately building the Release variant only.
    logs::out(log_tag!(), "Compiling SDL2 simulator release static library ...");
    scripts::run(&Script::new(
        r#"xcodebuild -project "SDL.xcodeproj" -scheme "Static Library-iOS" -configuration Release -sdk iphonesimulator -derivedDataPath "build_iphonesimulator" -arch arm64 -arch x86_64 only_active_arch=no"#
    ).working_dir(&xcode_project_dir))?;

    // We now need to build the static library for the phone target (ARM64).
    logs::out(log_tag!(), "Compiling SDL2 phone release static library ...");
    scripts::run(&Script::new(
        r#"xcodebuild -project "SDL.xcodeproj" -scheme "Static Library-iOS" -configuration Release -sdk iphoneos -derivedDataPath "build_iphoneos" -arch arm64 only_active_arch=no"#
    ).working_dir(&xcode_project_dir))?;

    // Once both the simulators + phone static libraries have been built, we need to merge them together into an XCFramework which will be embedded in the iOS project.
    let headers_path = context.working_dir.join(SDL2_DIR).join("include");
    logs::out(log_tag!(), "Creating SDL2 XCFramework ...");
    scripts::run(&Script::new(&format!(
        r#"xcodebuild -create-xcframework -library build_iphonesimulator/Build/Products/Release-iphonesimulator/libSDL2.a -headers {:?} -library build_iphoneos/Build/Products/Release-iphoneos/libSDL2.a -headers {:?} -output {:?}"#,
        &headers_path,
        &headers_path,
        &xcframework_path
    )).working_dir(&xcode_project_dir))?;

    Ok(())
}

fn setup_sdl2_image(context: &Context, frameworks_dir: &PathBuf) -> FailableUnit {
    let xcframework_path = frameworks_dir.join(SDL2_IMAGE_FRAMEWORK_NAME);

    // No need to build if its output already exist.
    if xcframework_path.exists() {
        return Ok(());
    }

    // The source code for SDL2 image needs to be available for us to build the framework.
    remote_zips::fetch(SDL2_IMAGE_URL, SDL2_IMAGE_DIR, &context.working_dir)?;

    // // This is the directory where the SDL2 Image Xcode project can be found which when compiled produces the static libraries we need.
    let xcode_project_dir = context.working_dir.join(SDL2_IMAGE_DIR).join("Xcode-iOS");

    // We need to build the static library for both simulators (ARM64 + x86_64). Note also that we are deliberately building the Release variant only.
    logs::out(log_tag!(), "Compiling SDL2 Image simulator release static library ...");
    scripts::run(&Script::new(
        r#"xcodebuild -project "SDL_image.xcodeproj" -scheme "libSDL_image-iOS" -configuration Release -sdk iphonesimulator -derivedDataPath "build_iphonesimulator" -arch arm64 -arch x86_64 only_active_arch=no"#
    ).working_dir(&xcode_project_dir))?;

    // We now need to build the static library for the phone target (ARM64).
    logs::out(log_tag!(), "Compiling SDL2 Image phone release static library ...");
    scripts::run(&Script::new(
        r#"xcodebuild -project "SDL_image.xcodeproj" -scheme "libSDL_image-iOS" -configuration Release -sdk iphoneos -derivedDataPath "build_iphoneos" -arch arm64 only_active_arch=no"#
    ).working_dir(&xcode_project_dir))?;

    // Once both the simulators + phone static libraries have been built, we need to merge them together into an XCFramework which will be embedded in the iOS project.
    // Note: We do not need to include the headers in the framework as we won't be accessing them in our iOS project directly.
    logs::out(log_tag!(), "Creating SDL2 Image XCFramework ...");
    scripts::run(&Script::new(&format!(
        r#"xcodebuild -create-xcframework -library build_iphonesimulator/Build/Products/Release-iphonesimulator/libSDL2_image.a -library build_iphoneos/Build/Products/Release-iphoneos/libSDL2_image.a -output {:?}"#,
    &xcframework_path)).working_dir(&xcode_project_dir))?;

    Ok(())
}

fn compile(context: &Context) -> FailableUnit {
    for architecture in &vec![
        ARCHITECTURE_ARM64_IPHONE,
        ARCHITECTURE_ARM64_SIMULATOR,
        ARCHITECTURE_X86_64_SIMULATOR,
    ] {
        logs::out(log_tag!(), &format!("Compiling crust for architecture: {}", &architecture));
        scripts::run(
            &Script::new(&format!(
                "cargo rustc {} --target-dir {:?} --lib --target {}",
                context.variant.rust_compiler_flag(),
                context.rust_build_dir,
                architecture,
            ))
            .working_dir(&context.working_dir),
        )?;
    }

    Ok(())
}

fn create_output(context: &Context, frameworks_dir: &PathBuf) -> FailableUnit {
    let variant_dir = context.variant.id();

    // Now that each of the architectures has been compiled, we need to generate an XCFramework which merges them all together for the iOS project.
    // When generating an XCFramework we must actually join any compiled architectures that belong to the same family together first - in our case
    // we have compiled 2 iOS simulator architectures (ARM64 + x86_64). If we were to try and make an XCFramework where each of the simulator
    // architectures were bundled separately, the XCFramework command would fail so we must first join them into 1 static library. To join compiled
    // architectures together we use the 'lipo' tool.
    logs::out(log_tag!(), "Joining iOS simulator architectures together ...");
    let simulator_static_library_dir = context
        .rust_build_dir
        .join(&format!("{}-{}", ARCHITECTURE_ARM64_SIMULATOR, ARCHITECTURE_X86_64_SIMULATOR))
        .join(variant_dir);

    // Make sure the path to our merged static library exists.
    io::create_dir(&simulator_static_library_dir)?;

    let simulator_static_library_path = simulator_static_library_dir.join("libcrustlib.a");

    scripts::run(
        &Script::new(&format!(
            "lipo -create -output {:?} {:?} {:?}",
            &simulator_static_library_path,
            &context.rust_build_dir.join(ARCHITECTURE_ARM64_SIMULATOR).join(variant_dir).join("libcrustlib.a"),
            &context.rust_build_dir.join(ARCHITECTURE_X86_64_SIMULATOR).join(variant_dir).join("libcrustlib.a"),
        ))
        .working_dir(&context.working_dir),
    )?;

    // Now that we have a static library for the iPhone architecture and a static library representing both iOS simulator architectures (via the lipo tool),
    // we can build the XCFramework which wraps it all up and can then be used as a framework dependency in the iOS project.
    let xcframework_path = frameworks_dir.join("crust.xcframework");
    let iphone_static_library_path =
        context.rust_build_dir.join(ARCHITECTURE_ARM64_IPHONE).join(variant_dir).join("libcrustlib.a");

    // Start off by deleting the existing framework if it exists - we should do this so building a debug/release variant always produces the appropriate XCFramework.
    io::delete(&xcframework_path)?;

    // Now run the appropriate Xcode command to create the framework.
    logs::out(log_tag!(), "Creating XCFramework for 'crust' ...");
    scripts::run(
        &Script::new(&format!(
            "xcodebuild -create-xcframework -library {:?} -library {:?} -output {:?}",
            &iphone_static_library_path, &simulator_static_library_path, &xcframework_path,
        ))
        .working_dir(&context.working_dir),
    )?;

    // The Frameworks directory now contains 'crust.xcframework' ready to be used in the iOS Xcode project.
    Ok(())
}

Save ios.rs, go back to Xcode and run the app again, triggering our new Rust build code. You should see a whole heap of crust-build activity when the shell script step is run and it may take some time to finish while it is downloading and compiling the SDL2 libraries and our Rust code:

   Compiling crust-build v1.0.0 (<snip>/crust-build)
    Finished dev [unoptimized + debuginfo] target(s) in 6.45s
     Running `target/debug/crust-build --target ios --variant Debug`
[ print_summary ] ---------------------------------------------
[ print_summary ] Assets dir:          "<snip>/crust-main/assets"
[ print_summary ] Working dir:         "<snip>/ios/.rust-build"
[ print_summary ] Rust build dir:      "<snip>/ios/.rust-build/rust"
[ print_summary ] Variant:             Debug
[ print_summary ] Target home dir:     "<snip>/ios"
[ print_summary ] Main source dir:     "<snip>/crust-main"
[ print_summary ] ---------------------------------------------
info: component 'rust-std' for target 'aarch64-apple-ios' is up to date
info: component 'rust-std' for target 'aarch64-apple-ios-sim' is up to date
info: component 'rust-std' for target 'x86_64-apple-ios' is up to date
[ delete ] Deleting "<snip>/ios/crust/Frameworks"
[ create_symlink ] Creating symlink: "<snip>/ios/.rust-build/Frameworks" <=> "<snip>/ios/crust/Frameworks"
[ download ] Download: "https://www.libsdl.org/release/SDL2-2.0.14.zip"
[ download ] Into: "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustfThdBT/download.zip"
[ download ] Download complete: "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustfThdBT/download.zip"
[ unzip ] Unzipping: "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustfThdBT/download.zip" => "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustfThdBT/unzipped"
[ rename ] Renaming: "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustfThdBT/unzipped/SDL2-2.0.14" => "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustfThdBT/unzipped/SDL"
[ setup_sdl2 ] Compiling SDL2 simulator release static library ...
...

When the build is complete (you will still get a build error which we’ll fix soon), you can observe the following new files and directories:

+ root
    + ios
        + .rust-build
        + crust
            + Frameworks (symlink)
                + crust.xcframework
                + SDL2.xcframework
                + SDL2_image.xcframework                

If you were to look inside one of the frameworks you will see that is a static library for two platforms:

Assets

We can now focus on the Xcode project again, first by adding our 3D models, textures and shader files. Right click on the crust folder in Xcode and add files to the project:

Select crust-main/assets and leave the options as shown below:

Your project should then look like this:

Similar to the MacOS Desktop project, these assets will be bundled into our resulting iOS app during the build.

Frameworks

Next up we will add all the required frameworks - there are actually quite a few to add but most of them are Apple ones.

Navigate to the Frameworks, Libraries and Embedded Content section in the Build tab of the crust target:

One by one, add the following frameworks, leaving them as Do Not Embed:

Next we will add our SDL and Rust frameworks that we compiled ourselves. Add a new framework and select the Add Other -> Add Files option. Select the three xcframework directories in our Frameworks symlink location - leave them as Embed & Sign:

The frameworks section should look like this when you are done:

Now go to the Build Settings tab and add an entry to the Framework Search Paths field of $(SRCROOT)/Frameworks - this is very similar to what we did in the MacOS Desktop target and allows our custom frameworks to be found at build time.

Bootstrap

One of the main differences between the iOS and MacOS targets is that on iOS we don’t actually compile a binary version of crust - instead we made the crust.xcframework which is really more of a library. In order to launch our crust application we actually need for this method to be run which we introduced during the Android article:

#[cfg(any(target_os = "android", target_os = "ios"))]
#[no_mangle]
pub extern "C" fn SDL_main(_argc: libc::c_int, _argv: *const *const libc::c_char) -> libc::c_int {
    main();
    return 0;
}

The SDL_main method is a bit magical - the SDL framework itself will automatically run this method instead of a main method, so long as include the file SDL.h in our code. Edit main.m and replace it with:

// Including the SDL header will cause the default main function
// to be redirected to the SDL main method instead. This means
// we don't even have to bootstrap our application - it will
// happen automatically.
#include "SDL.h"

It seems a bit crazy but that is literally all we need to do for our crust code to be invoked via the SDL_main we included.

Now run the app again and bask in the brilliance of crust on iOS!

Summary

Woohoo! That was the last of our target platforms integrated with crust!

The code for this article can be found here.

Continue to Part 13: Summary.

End of part 12