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:
crust-build
as part of its build pipelinecrust-build
projectYou 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):
AppDelegate.h
AppDelegate.m
ViewController.h
ViewController.m
Main.storyboard
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
:
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
.
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.
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.
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.
Awesome, we have one more target to go - iOS!
The code for this article can be found here.
End of part 11