Using rust's target runner for iOS simulators

Introduction§

I've become a pretty big fan for rust and cargo. I've found it quite convenient to run cargo run or cargo test. This doesn't work quite as nicely if you're cross compiling. Fortunately, there's the target.<tripple>.runner cargo feature. Unfortunately, I've found it hard to find example uses of this. An advanced search on GitHub for runner = in config.toml is how I've found other examples.

In this post, I demonstrate an iOS simulator target runner script. The way the app's Info.plist is setup is liable to change and break in the future but this works with macOS 13.3, Xcode 14.1 targeted at the iOS 16.1 simulator.

The source code for this post is available in the code subdirectory of this repo if you want to use it.

Prior work§

I discovered cargo dinghy a few years ago and added support for iOS simulator tests in CI. My complaints about using dinghy here are:

  • CI compiling dinghy (if you don't have it cached) and then compiling the project. I've found this to result in longer CI times.
  • Recalling the arguments to dinghy such as cargo dinghy --platform auto-ios-aarch64-sim test

Setup§

To get started, you need to specify the target runner for cargo

[target.aarch64-apple-ios-sim]
runner = "./ios-sim-runner.sh"
[target.x86_64-apple-ios]
runner = "./ios-sim-runner.sh"

This cargo config tells cargo what script/executable to use when running cargo run --target aarch64-apple-ios-sim -- arg1 arg2. The arguments to this script/executable are the path to the target binary and then the arguments to said binary.

Here's the ios-sim-runner.sh script.

#!/bin/sh
set -o pipefail

EXECUTABLE=$1
ARGS=${@:2}

IDENTIFIER="com.simlay.net.RustRunner"
DISPLAY_NAME="RustRunner"
BUNDLE_NAME=${DISPLAY_NAME}.App
EXECUTABLE_NAME=$(basename ${EXECUTABLE})
BUNDLE_PATH=$(dirname $EXECUTABLE)/${BUNDLE_NAME}

PLIST="<?xml version=\"1.0\" encoding=\"UTF-8\"?>
    <!DOCTYPE plist PUBLIC \"-//Apple Computer//DTD PLIST 1.0//EN\"
    \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
    <plist version=\"1.0\">
    <dict>
    <key>CFBundleIdentifier</key>
    <string>${IDENTIFIER}</string>
    <key>CFBundleDisplayName</key>
    <string>${DISPLAY_NAME}</string>
    <key>CFBundleName</key>
    <string>${BUNDLE_NAME}</string>
    <key>CFBundleExecutable</key>
    <string>${EXECUTABLE_NAME}</string>
    <key>CFBundleVersion</key>
    <string>0.0.1</string>
    <key>CFBundleShortVersionString</key>
    <string>0.0.1</string>
    <key>CFBundleDevelopmentRegion</key>
    <string>en_US</string>
    <key>UILaunchStoryboardName</key>
    <string></string>
    <key>CFBundleIconFiles</key>
    <array>
    </array>
    <key>LSRequiresIPhoneOS</key>
    <true/>
    </dict>
</plist>"

rm -rf "${BUNDLE_PATH}" 2> /dev/null
mkdir -p ${BUNDLE_PATH}

echo $PLIST > ${BUNDLE_PATH}/Info.plist
cp ${EXECUTABLE} ${BUNDLE_PATH}

# Some simctl helper functions
ios_runtime() {
    xcrun simctl list -j runtimes ios | \
        jq -r '.[] | sort_by(.identifier)[0].identifier'
}
ios_devices() {
    xcrun simctl list -j devices ios | \
        jq ".devices.\"$(ios_runtime)\"" | jq 'sort_by(.deviceTypeIdentifier)'
}

ios_devices_booted() {
    ios_devices | jq '[.[] | select(.state == "Booted")]'
}

ios_devices_shutdown() {
    ios_devices | jq '[.[] | select(.state == "Shutdown")]'
}

ios_devices_get_id() {
    booted=$(ios_devices_booted)
    if [ "$booted" != "[]" ]; then
        echo $booted | jq -r '.[0].udid'
    else
        device_id=$(ios_devices_shutdown | jq -r '.[0].udid')
        xcrun simctl boot $device_id
        echo $device_id
    fi
}
DEVICE_ID=$(ios_devices_get_id)

# Install/reinstall the app, Start the app in the iOS simulator but wait for
# the debugger to attach
xcrun simctl uninstall ${DEVICE_ID} ${IDENTIFIER}
xcrun simctl install ${DEVICE_ID} ${BUNDLE_PATH}
INSTALLED_PATH=$(xcrun simctl get_app_container ${DEVICE_ID} ${IDENTIFIER})
APP_STDOUT=${INSTALLED_PATH}/stdout
APP_STDERR=${INSTALLED_PATH}/stderr

APP_PID=$(\
    xcrun simctl launch -w \
    --stdout=${APP_STDOUT} \
    --stderr=${APP_STDERR} \
    --terminate-running-process ${DEVICE_ID} ${IDENTIFIER} ${ARGS} \
    | awk -F: '{print $2}')

# Attach to the app using lldb.
LLDB_SCRIPT_FILE=$(mktemp /tmp/lldb_script.XXXXX)
echo "attach ${APP_PID}" >> ${LLDB_SCRIPT_FILE}
echo "continue" >> ${LLDB_SCRIPT_FILE}
echo "quit" >> ${LLDB_SCRIPT_FILE}

LLDB_OUT_FILE=$(mktemp /tmp/lldb_script.XXXXX)
lldb -s ${LLDB_SCRIPT_FILE} > ${LLDB_OUT_FILE}

# This is the stdout LLDB:
#
# (lldb) command source -s 0 '/tmp/lldb.xijJI'
# Executing commands in '/tmp/lldb.xijJI'.
# (lldb) attach  89772
# Process 89772 stopped
# * thread #1, stop reason = signal SIGSTOP
#     frame #0: 0x0000000102b00a40 dyld`_dyld_start
# dyld`:
# ->  0x102b00a40 <+0>:  mov    x0, sp
#     0x102b00a44 <+4>:  and    sp, x0, #0xfffffffffffffff0
#     0x102b00a48 <+8>:  mov    x29, #0x0
#     0x102b00a4c <+12>: mov    x30, #0x0
# Target 0: (test_runner-8652616abdef98d9) stopped.
# Executable module set to "THE PATH TO THE IOS BUNDLED APP IN THE APP".
# Architecture set to: arm64e-apple-ios-simulator.
# (lldb) continue
# Process 89772 resuming
# Process 89772 exited with status = 0 (0x00000000)
# (lldb) quit


# Parse the output of lldb to retrieve the status code.
STATUS_CODE=$(\
    cat ${LLDB_OUT_FILE} | \
    grep "Process \d\+ exited with status = \d\+" | \
    grep ${APP_PID} | \
    grep -o "= \d\+" | \
    sed 's/= //g'\
)
cat ${APP_STDOUT}
cat ${APP_STDERR} >&2

exit ${STATUS_CODE}

Usage§

With a simple rust main.rs or lib.rs:

fn main() {
    println!("Hello world args - {:?}", std::env::args());
    let first = 2;
    let second = 3;
    println!("add {} + {} = {}", first, second, add(first, second));
}

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn this_test_passes() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn this_test_fails() {
        let result = add(2, 2);
        assert_eq!(result, 5);
    }
}

Put the ios-sim-runner.sh in your path or local to the project and the .cargo/config.toml in desired cargo configuration and then you'll be able to run:

cargo test --target x86_64-apple-ios or cargo test --target aarch64-apple-ios-sim and you'll see something like the following:

    Finished test [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/main.rs (target/aarch64-apple-ios-sim/debug/deps/rust_target_runner_for_ios-21a08454287c0bcd)

running 2 tests
test tests::this_test_passes ... ok
test tests::this_test_fails ... FAILED

failures:

---- tests::this_test_fails stdout ----
thread 'tests::this_test_fails' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`', src/main.rs:26:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_fails

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--bin rust-target-runner-for-ios`

One can do cargo test --target aarch64-apple-ios-sim -- this_test_passes or cargo run --target aarch64-apple-ios-sim

Description§

Given that this script will certainly break in the future due to changes with either simctl or the Info.plist specifications so I'll tell you about the sections.

App Bundle§

IDENTIFIER="com.simlay.net.RustRunner"
DISPLAY_NAME="RustRunner"
BUNDLE_NAME=${DISPLAY_NAME}.App
EXECUTABLE_NAME=$(basename ${EXECUTABLE})
BUNDLE_PATH=$(dirname $EXECUTABLE)/${BUNDLE_NAME}

PLIST="<?xml version=\"1.0\" encoding=\"UTF-8\"?>
    <!DOCTYPE plist PUBLIC \"-//Apple Computer//DTD PLIST 1.0//EN\"
    \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
    <plist version=\"1.0\">
    <dict>
    <key>CFBundleIdentifier</key>
    <string>${IDENTIFIER}</string>
    <key>CFBundleDisplayName</key>
    <string>${DISPLAY_NAME}</string>
    <key>CFBundleName</key>
    <string>${BUNDLE_NAME}</string>
    <key>CFBundleExecutable</key>
    <string>${EXECUTABLE_NAME}</string>
    <key>CFBundleVersion</key>
    <string>0.0.1</string>
    <key>CFBundleShortVersionString</key>
    <string>0.0.1</string>
    <key>CFBundleDevelopmentRegion</key>
    <string>en_US</string>
    <key>UILaunchStoryboardName</key>
    <string></string>
    <key>CFBundleIconFiles</key>
    <array>
    </array>
    <key>LSRequiresIPhoneOS</key>
    <true/>
    </dict>
</plist>"

rm -rf "${BUNDLE_PATH}" 2> /dev/null
mkdir -p ${BUNDLE_PATH}

echo $PLIST > ${BUNDLE_PATH}/Info.plist
cp ${EXECUTABLE} ${BUNDLE_PATH}

This is the section to bundle the executable that's the first argument into an iOS simulator app.

Device ID§

ios_runtime() {
    xcrun simctl list -j runtimes ios | \
        jq -r '.[] | sort_by(.identifier)[0].identifier'
}
ios_devices() {
    xcrun simctl list -j devices ios | \
        jq ".devices.\"$(ios_runtime)\"" | jq 'sort_by(.deviceTypeIdentifier)'
}

ios_devices_booted() {
    ios_devices | jq '[.[] | select(.state == "Booted")]'
}

ios_devices_shutdown() {
    ios_devices | jq '[.[] | select(.state == "Shutdown")]'
}

ios_devices_get_id() {
    booted=$(ios_devices_booted)
    if [ "$booted" != "[]" ]; then
        echo $booted | jq -r '.[0].udid'
    else
        device_id=$(ios_devices_shutdown | jq -r '.[0].udid')
        xcrun simctl boot $device_id
        echo $device_id
    fi
}
DEVICE_ID=$(ios_devices_get_id)

This whole section is about getting the DEVICE_ID as this is needed for the get_app_container. Otherwise, one can just use booted for most cases of device id.

This will start an iOS simulator if one is not started.

Start the app§

xcrun simctl uninstall ${DEVICE_ID} ${IDENTIFIER}
xcrun simctl install ${DEVICE_ID} ${BUNDLE_PATH}
INSTALLED_PATH=$(xcrun simctl get_app_container ${DEVICE_ID} ${IDENTIFIER})
APP_STDOUT=${INSTALLED_PATH}/stdout
APP_STDERR=${INSTALLED_PATH}/stderr

APP_PID=$(\
    xcrun simctl launch -w \
    --stdout=${APP_STDOUT} \
    --stderr=${APP_STDERR} \
    --terminate-running-process ${DEVICE_ID} ${IDENTIFIER} ${ARGS} \
    | awk -F: '{print $2}')

Here we install the app bundle and start the app in waiting/debugger mode as we'll later use the PID to retrieve the exit status of the app and propagate the status code up.

LLDB§

LLDB_SCRIPT_FILE=$(mktemp /tmp/lldb_script.XXXXX)
echo "attach ${APP_PID}" >> ${LLDB_SCRIPT_FILE}
echo "continue" >> ${LLDB_SCRIPT_FILE}
echo "quit" >> ${LLDB_SCRIPT_FILE}

LLDB_OUT_FILE=$(mktemp /tmp/lldb_script.XXXXX)
lldb -s ${LLDB_SCRIPT_FILE} > ${LLDB_OUT_FILE}
# This is the stdout LLDB:
#
# (lldb) command source -s 0 '/tmp/lldb.xijJI'
# Executing commands in '/tmp/lldb.xijJI'.
# (lldb) attach  89772
# Process 89772 stopped
# * thread #1, stop reason = signal SIGSTOP
#     frame #0: 0x0000000102b00a40 dyld`_dyld_start
# dyld`:
# ->  0x102b00a40 <+0>:  mov    x0, sp
#     0x102b00a44 <+4>:  and    sp, x0, #0xfffffffffffffff0
#     0x102b00a48 <+8>:  mov    x29, #0x0
#     0x102b00a4c <+12>: mov    x30, #0x0
# Target 0: (test_runner-8652616abdef98d9) stopped.
# Executable module set to "THE PATH TO THE IOS BUNDLED APP IN THE APP".
# Architecture set to: arm64e-apple-ios-simulator.
# (lldb) continue
# Process 89772 resuming
# Process 89772 exited with status = 0 (0x00000000)
# (lldb) quit


# Parse the output of lldb to retrieve the status code.
STATUS_CODE=$(\
    cat ${LLDB_OUT_FILE} | \
    grep "Process \d\+ exited with status = \d\+" | \
    grep ${APP_PID} | \
    grep -o "= \d\+" | \
    sed 's/= //g'\
)
cat ${APP_STDOUT}
cat ${APP_STDERR} >&2

exit ${STATUS_CODE}

Here we:

  • create a very simple lldb script
  • run said lldb script
  • read the stdout, parse it, and retrieve the exit status.
  • exit with the status code.

Future Work§

In the future I'd like to use ios-deploy to deploy/run/test over wifi. The hard part about this is the requirements and uses of codesigning the app