Fixing Simulator Status Bars for App Store Screenshots With Xcode 11 and iOS 13

Today Apple released the Xcode 11 GM release, meaning that it’s now time to create our builds using the iOS 13 SDK and submit to the App Store! If you’re like me and you use Fastlane’s Snapshot tool to automate the creation of your App Store screenshots using Xcode’s UI testing infrastructure, then you may have noticed something that broke with this new SDK: using SimulatorStatusMagic to clean up the simulator’s status bar. I use it on Landmarked, a side project I have at Detroit Labs, to make the App Store screenshots just a little nicer:

A screenshot of Landmarked

As you can tell, the status bar doesn’t say “Carrier,” the battery is full (important if you’re developing on a laptop), and the time has been set. In this case, since Landmarked is about Detroit, I set the time to 3:13 PM, but you may want to set it to 9:41 AM as per tradition. Doing this with SimulatorStatusMagic is easy:

#if targetEnvironment(simulator)
SDStatusBarManager.sharedInstance().bluetoothState = .hidden
SDStatusBarManager.sharedInstance().batteryDetailEnabled = false
SDStatusBarManager.sharedInstance().timeString = "3:13 PM"
SDStatusBarManager.sharedInstance().enableOverrides()
#endif

I use the #if targetEnvironment(simulator) bit to ensure that this doesn’t run if you’re running the UI tests on a real device, as it isn’t supported. Up until now, this has worked great. Of course, you should clean this up when you’re done—otherwise, these overrides persist across app launches on the running simulator. The above code runs in the test case’s setUp() method, and in the tearDown() method, you can clean up your work:

override func tearDown() {
    super.tearDown()
    #if targetEnvironment(simulator)
    SDStatusBarManager.sharedInstance().disableOverrides()
    #endif
}

So how do we do this on Xcode 11? With SimulatorStatusMagic not working, the first step is to avoid running it on iOS 13:

override func setUp() {
    #if targetEnvironment(simulator)
    if #available(iOS 13.0, *) {
        
    }
    else {
        SDStatusBarManager.sharedInstance().bluetoothState = .hidden
        SDStatusBarManager.sharedInstance().batteryDetailEnabled = false
        SDStatusBarManager.sharedInstance().timeString = "3:13 PM"
        SDStatusBarManager.sharedInstance().enableOverrides()
    }
    #endif
}
override func tearDown() {
    super.tearDown()

    #if targetEnvironment(simulator)
    if #available(iOS 13.0, *) {
        
    }
    else {
        SDStatusBarManager.sharedInstance().disableOverrides()
    }
    #endif
}

Looking at the release notes for Xcode 11, there’s a new simctl command to control the simulator status bar, aptly named status_bar. At the command line, to replicate the above behavior, we’d call it like this:

xcrun simctl status_bar booted override \
    --time "3:13 PM" \
    --dataNetwork wifi \
    --wifiMode active \
    --wifiBars 3 \
    --cellularMode notSupported \
    --batteryState discharging \
    --batteryLevel 100

But if you want to run this automatically, where to put it? You can’t call out to Process on iOS, but you need to call something on your Mac to run this command. Fortunately, Xcode has a way to do this by adding a pre-action to your test scheme:

A screenshot of Xcode editing a scheme’s pre-run actions

And of course, just as before, you can use a post-run action with the clear command to reset everything:

A screenshot of Xcode editing a scheme’s post-run actions

It works! Huzzah! Of course, there are still some things we can do better. If you run these UI tests on a device, and you have a simulator open, then that simulator will get these overrides while it’s running thanks to the booted parameter passed to simctl. Also, this approach doesn’t work when automating with Fastlane. To avoid that, we can use the TARGET_DEVICE_IDENTIFIER parameter to specifically boot and configure the device that the tests will run on (if the device is not booted, then simctl will fail to set the overrides). In your pre-action, make sure you’re inheriting build settings from the UI testing target, then add this snippet:

xcrun simctl boot "${TARGET_DEVICE_IDENTIFIER}"

xcrun simctl status_bar "${TARGET_DEVICE_IDENTIFIER}" override \
    --time "3:13 PM" \
    --dataNetwork wifi \
    --wifiMode active \
    --wifiBars 3 \
    --cellularMode notSupported \
    --batteryState discharging \
    --batteryLevel 100

With that, we’re successfully configuring our status bar on the simulator when it runs. Of course, the value of TARGET_DEVICE_IDENTIFIER won’t make sense to simctl if the destination is a device. Let’s add a quick check that we’re running on a simulator, and if not, exit early:

if [[ "${SDKROOT}" != *"simulator"* ]]; then
    exit 0
fi

Perfect! The value of SDKROOT is something like iphonesimulator13.0 when running on a simulator and iphoneos13 when running on a device, so this if statement will call exit when the SDK isn’t a simulator. Next up, we want to avoid running this code when the destination isn’t running at least iOS 13, so to do that, we’ll need to check the target device’s iOS version. Luckily for me, that code was readily available on StackExchange. The final version of our script looks like this:

function version {
    echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }';
}

# Don’t run on iOS devices.
if [[ "${SDKROOT}" != *"simulator"* ]]; then
    exit 0
fi

# Don’t run on iOS versions before 13.
if [ $(version "${TARGET_DEVICE_OS_VERSION}") -ge $(version "13") ]; then
    xcrun simctl boot "${TARGET_DEVICE_IDENTIFIER}"

    xcrun simctl status_bar "${TARGET_DEVICE_IDENTIFIER}" override \
        --time "3:13 PM" \
        --dataNetwork wifi \
        --wifiMode active \
        --wifiBars 3 \
        --cellularMode notSupported \
        --batteryState discharging \
        --batteryLevel 100
fi

Now, if you run your UI tests on an iOS simulator target running anything earlier than iOS 13, the pre-run action will exit early, and SimulatorStatusMagic will take care of it. This is important for our purposes as Landmarked still supports iOS 9 and the iPhone 4S’s 3.5″ screen, so we need to generate a screenshot for that size to be complete.

With this, our pre-run script is complete! The post-run is much simpler:

function version {
    echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }';
}

# Don’t run on iOS devices.
if [[ "${SDKROOT}" != *"simulator"* ]]; then
    exit 0
fi

# Don’t run on iOS versions before 13.
if [ $(version "${TARGET_DEVICE_OS_VERSION}") -ge $(version "13") ]; then
    xcrun simctl boot "${TARGET_DEVICE_IDENTIFIER}"
    xcrun simctl status_bar "${TARGET_DEVICE_IDENTIFIER}" clear
fi

I was able to use the above code to generate all of the screenshots for the iOS 13 version of Landmarked. Keep an eye out for version 1.1.3 as soon as iOS 13 is released! Here’s what it looks like on the new iPhone 11 Pro Max:

A screenshot of Landmarked on an iPhone 11 Pro Max.