crust / Part 11 - MacOS Desktop

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 MacOS Dekstop target platform. This will let us run crust as a bundled MacOS application - such that it could also be published to the Apple Mac App Store - though you would need an active Apple Developer subscription to do that. We will:


Xcode project

You may recall that for the MacOS Console application we didn’t create an Xcode project at all, instead just running crust from a directory that knew where to find the appropriate SDL frameworks and assets. For the MacOS Desktop target however, we will take an approach similar to Android where we create a project in the native tooling (Xcode in this case) which will invoke our crust-build as part of its build pipeline.

Start off by creating a new directory named macos-desktop in your crust workspace, so you have a structure like:

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

Create Xcode project

Launch Xcode and create a new MacOS app project:

Set the project name to crust and pick the settings shown below.

Note: There is no need to include Swift support as we will barely write any Apple code.

Save the new project in your macos-desktop directory, afterward you should have:

+ root
    ...
    + macos-desktop
        + crust
            + crust
            + crust.xcodeproj

Delete unwanted files

As we aren’t authoring an Apple UI application we can get rid of a bunch of files that won’t be needed. Delete the following files from the project (choose Move To Trash when asked):

Delete the Main from the Main Interface field in the Deployment Info section also:

Build script

We are going to be compiling and building our Rust code into a crust binary file, which will be bundled into the output of the Xcode project. To do this, we’ll invoke our crust-build command as an Xcode build phase using a basic Xcode driven shell script. Navigate to the Build Phases section of the crust target and add a New Run Script Phase:

Drag the new Run Script step as far to the top as possible - just under the Dependencies step, uncheck the Based on dependency analysis checkbox, then 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 macos-desktop --variant $CONFIGURATION

When running shell scripts from Xcode we don’t get anything that might usually be set through .bashrc, .bash.profile or .zshrc. This means that by default the PATH environment variable won’t contain the location of homebrew or any of its installed packages such as xcodegen, which we need in order to build the SDL frameworks.

To work around this problem we need to explicitly set the PATH environment variable ourselves and include the typical locations of the binary tools needed in the shell script. For crust-build this means knowing the location of the xcodegen tool and the cargo command which resides by default at ~/.cargo/bin/.

After setting the correct PATH we change into the crust-build directory and run the typical cargo command to build the macos-desktop Rust target. The $CONFIGURATION property is provided into the shell environment by Xcode itself and will be either Debug or Release - this was one of the reasons for allowing the --variant argument to be case insensitive within crust-build.

If you run the project and go to the build output in the Report navigator pane you can see that our crust-build code was invoked, though we still have to implement the actual build code for the macos-desktop target in crust-build:

Rust build implementation

The code we have to add in crust-build is actually quite small. We will also be able to reuse the SDL framework code from our MacOS Console target that we wrote at the start of the series to save us some effort. Update crust-build/src/macos_desktop.rs with:

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

const ARCHITECTURE_X86_64: &str = "x86_64-apple-darwin";
const ARCHITECTURE_ARM64: &str = "aarch64-apple-darwin";

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

    install_rust_dependencies()?;

    let frameworks_dir = macos_sdl::setup(context)?;
    link_frameworks(context, &frameworks_dir)?;
    compile(context)?;
    create_output(context)?;

    Ok(())
}

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

fn link_frameworks(context: &Context, frameworks_dir: &PathBuf) -> FailableUnit {
    io::create_symlink(frameworks_dir, &context.target_home_dir.join("crust").join("Frameworks"), &context.working_dir)
}

fn compile(context: &Context) -> FailableUnit {
    for architecture in &vec![ARCHITECTURE_X86_64, ARCHITECTURE_ARM64] {
        logs::out(log_tag!(), &format!("Compiling architecture: {} ...", &architecture));

        scripts::run(&Script::new(&format!(
            "cargo rustc {} --manifest-path {:?} --target {} --bin crust --target-dir {:?} -- -L framework={:?}",
            context.variant.rust_compiler_flag(),
            context.source_dir.join("Cargo.toml"),
            &architecture,
            context.rust_build_dir,
            context.working_dir.join("Frameworks"),
        )))?;
    }

    Ok(())
}

fn create_output(context: &Context) -> FailableUnit {
    let output_binary_dir = context.target_home_dir.join("crust").join("crust");
    let x86_64_binary = context.rust_build_dir.join(ARCHITECTURE_X86_64).join(context.variant.id()).join("crust");
    let arm64_binary = context.rust_build_dir.join(ARCHITECTURE_ARM64).join(context.variant.id()).join("crust");

    scripts::run(
        &Script::new(&format!("lipo -create -output crust {:?} {:?}", &x86_64_binary, &arm64_binary))
            .working_dir(&output_binary_dir),
    )?;

    scripts::run(&Script::new("install_name_tool -add_rpath @loader_path/../Frameworks crust").working_dir(&output_binary_dir))
}

Install rust dependencies

Installs the Rust toolchains for x86_64-apple-darwin and aarch64-apple-darwin - this is so we can build a crust binary that is compatible with both Intel and Apple Silicon hardware:

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

Setup SDL

The setup of the SDL2 and SDL2 Image frameworks is done using our shared macos_sdl::setup code from our MacOS Console implementation - the same frameworks can be used for the MacOS Desktop target as well:

pub fn build(context: &Context) -> FailableUnit {
    ...
    let frameworks_dir = macos_sdl::setup(context)?;

Link SDL frameworks

Creates a symlink to the compiled SDL frameworks within the Xcode project directory structure - we will add the symlinked Frameworks directory to the Xcode project soon.

fn link_frameworks(context: &Context, frameworks_dir: &PathBuf) -> FailableUnit {
    io::create_symlink(frameworks_dir, &context.target_home_dir.join("crust").join("Frameworks"), &context.working_dir)
}

We would end up with a symlink directory like this:

+ root
    + macos-desktop
        + crust
            + Frameworks

Compile Rust code

Compiles our crust-main Rust code into both the x86_64 and aarch64 architectures. Note that we pass the -L framework={:?} argument to link against the compiled SDL frameworks:

fn compile(context: &Context) -> FailableUnit {
    for architecture in &vec![ARCHITECTURE_X86_64, ARCHITECTURE_ARM64] {
        logs::out(log_tag!(), &format!("Compiling architecture: {} ...", &architecture));

        scripts::run(&Script::new(&format!(
            "cargo rustc {} --manifest-path {:?} --target {} --bin crust --target-dir {:?} -- -L framework={:?}",
            context.variant.rust_compiler_flag(),
            context.source_dir.join("Cargo.toml"),
            &architecture,
            context.rust_build_dir,
            context.working_dir.join("Frameworks"),
        )))?;
    }

    Ok(())
}

Create output

We use the lipo tool to combine the x86_64 and aarch64 binaries into a single universal binary named crust. The binary is then placed into the Xcode project directory structure so it can be bundled into the Xcode build. We then update it using install_name_tool to help it find the Frameworks relative to itself.

fn create_output(context: &Context) -> FailableUnit {
    let output_binary_dir = context.target_home_dir.join("crust").join("crust");
    let x86_64_binary = context.rust_build_dir.join(ARCHITECTURE_X86_64).join(context.variant.id()).join("crust");
    let arm64_binary = context.rust_build_dir.join(ARCHITECTURE_ARM64).join(context.variant.id()).join("crust");

    scripts::run(
        &Script::new(&format!("lipo -create -output crust {:?} {:?}", &x86_64_binary, &arm64_binary))
            .working_dir(&output_binary_dir),
    )?;

    scripts::run(&Script::new("install_name_tool -add_rpath @loader_path/../Frameworks crust").working_dir(&output_binary_dir))
}

Save macos_desktop.rs and go back to Xcode. Run the Xcode project again and this time you will see our crust-build building the SDL frameworks and compiling our Rust code:

   Compiling crust-build v1.0.0 (<snip>/crust-build)
    Finished dev [unoptimized + debuginfo] target(s) in 5.91s
     Running `target/debug/crust-build --target macos-desktop --variant Debug`
[ print_summary ] ---------------------------------------------
[ print_summary ] Assets dir:          "<snip>/crust-main/assets"
[ print_summary ] Working dir:         "<snip>/macos-desktop/.rust-build"
[ print_summary ] Rust build dir:      "<snip>/macos-desktop/.rust-build/rust"
[ print_summary ] Variant:             Debug
[ print_summary ] Target home dir:     "<snip>/macos-desktop"
[ print_summary ] Main source dir:     "<snip>/crust-main"
[ print_summary ] ---------------------------------------------
info: component 'rust-std' for target 'x86_64-apple-darwin' is up to date
info: component 'rust-std' for target 'aarch64-apple-darwin' is up to date
[ download ] Download: "https://www.libsdl.org/release/SDL2-2.0.14.zip"
[ download ] Into: "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustC3K5NN/download.zip"
[ download ] Download complete: "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustC3K5NN/download.zip"
[ unzip ] Unzipping: "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustC3K5NN/download.zip" => "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustC3K5NN/unzipped"
[ rename ] Renaming: "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustC3K5NN/unzipped/SDL2-2.0.14" => "/var/folders/ln/d7j2d7pn5jb8z3vdvvmttp_r0000gn/T/crustC3K5NN/unzipped/SDL2"
[ setup_sdl2 ] Compiling Xcode framework for SDL2, this may take a while ...
...

It may take a while to complete for the first time as it is building the SDL frameworks - just like for the MacOS Console project. When it is complete you should now see a Frameworks directory, a Frameworks symlink and a crust binary file like so:

+ root
    + macos-desktop
        + .rust-build
            + Frameworks
                + SDL2_image.framework
                + SDL2.framework
                ...
        + crust
            + crust
                - crust (universal binary file)
            ...
            + Frameworks (symlink)

Although there are a lot of crust directory and file names, the universal binary of our Rust code is in macos-desktop/crust/crust/crust. The macos-desktop/crust/Frameworks is a symlink pointing at macos-desktop/.rust-build/Frameworks.

Add generated files to Xcode project

The Rust build code is now done, the remaining steps are to add the outputs of our Rust build as members of the Xcode project.

Assets

To bundle our 3D models, textures and shaders we’ll add the assets directory as a reference to the Xcode project. Right click on the crust project directory and choose Add Files to "crust":

Navigate and select crust-main/assets, make sure to add it as a reference and leave the Copy items if needed unselected:

You should end up with a blue folder named assets in Xcode. By default, the content of this folder will be bundled into the resources of the Xcode app during the build, making them available to crust:

Frameworks

We will now add the required frameworks to the Xcode project. Start off by adding the OpenGL.framework that is available from Apple:

After adding it, leave the Embed setting as Do Not Embed - there is no need to bundle OpenGL support explicitly:

Next we will add the SDL frameworks which we compiled ourselves and that are stored in the Frameworks symlink directory. Add a new framework and select Add Other -> Add Files in the dialog:

Navigate into the Frameworks symlink directory and select the SDL2.framework and SDL2_image.framework directories as shown and add them:

Leave the new frameworks as Embed & Sign:

We also need to update the Xcode build setting which defines where to look for frameworks during compilation. Navigate to the Build Settings tab and find the Framework Search Paths field. Add a new entry with $(SRCROOT)/Frameworks - Xcode will resolve $(SRCROOT) to being the source root of our project:

Universal binary

Ok so we now have our assets and frameworks in the project, the last thing is to add the crust universal binary and update the bootstrap code to launch it in its main process. Right click on the crust folder in the Xcode project and choose Add Files to "crust":

Select the crust file at macos-desktop/crust/crust/crust - leave Copy items if needed disabled:

You should now have the crust universal binary as a member of the Xcode project:

Next up we need to update main.m to load up the universal binary file and execute it. Replace the content of main.m with:

#import <Cocoa/Cocoa.h>

int main(int argc, const char * argv[]) {
    NSTask *task = [NSTask new];
    task.launchPath = [[NSBundle mainBundle] pathForResource:@"crust" ofType:nil];
    [task launch];
    [task waitUntilExit];
}

The code here creates a new NSTask and tells it to launch the crust binary in the bundled resources. It then performs a waitUntilExit command on the task, effectively letting crust loop continually until it signals it is finished.

Drum roll!

Alrighty - run the project again and now you should see crust in all its glory!

You can find the fully bundled MacOS Desktop application at DerivedData/crust/Build/Products/Debug/crust.app:

You can actually copy crust.app and run it as a self contained program from anywhere. If you wanted to make an archive release build you would need an Apple Developer account but I’ll leave that to you.

Git ignore

Before we finish we’ll add a Git ignore rule so we don’t commit the crust universal binary file to version control - there is no need as it is automatically generated when we do a build. Add a new file macos-desktop/crust/crust/.gitignore with the following:

/crust

Note: We already put Git ignore rules in earlier to ignore DerivedData, xcuserdata and a few other directory names so we only need to add this specific rule for the universal binary file.

Summary

Awesome, we have one more target to go - iOS!

The code for this article can be found here.

Continue to Part 12: iOS.

End of part 11