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 codesign
ing the
app