Maintaining Sanity by Preloading App Data for Screenshots

Posted 3 weeks, 6 days ago.

Preparing screenshots for new versions of GIFwrapped has always been something of a hands-on process for me, despite using fastlane’s snapshot tool. Each time, I’d have to build GIFwrapped to a bunch of simulators, load a set of GIFs into each one by hand, then run my screenshots script.

Theoretically, this would be where Xcode’s xcappdata bundles would come in super handy, but since they don’t really work in simulators, they’re not very useful. Oh sure, the option is there and it totally works for actual devices, both for running tests and for debugging… but literally nothing happens for the simulator, which is less than ideal. There’s already a bunch of radars around this issue, so hopefully we’ll see it fixed at some point.

In the meantime, rather than continuing to struggle through that monotony, I decided to set up some code to handle it all for me. Because what even is life if you can’t automate away your annoyances?

We’re Loading What, Exactly?

It’s worth noting that I’m still going to use an xcappdata bundle to manage the actual content. Partly because they’re easy to create, supported by version control, and not difficult to modify. Mostly though, it’s because if Apple actually fix the ability to preload them into simulators, I can just delete all this and not have to think about it.

My UITests.xcappdata bundle doesn’t contain a lot, really: some GIFs, a couple of UserDefaults plists, and a file with Library metadata. This is all I really need to get things set up, but it could contain anything: a sqlite data store, a bunch of cached thumbnails… it’s really just a snapshot of the files in your app’s directory, nothing more, nothing less.

Finder, displaying the contents and structure of an example xcappdata bundle.

This is my xcappdata bundle. There are many like it, but this one is mine.

If you’ve never created one before, it’s as easy as going into Xcode’s Devices panel and selecting “Download Container” for the app/device combo you want to grab a copy of. Xcode will round up everything in the app’s directory (not app groups though, so plan for that) and create a new xcappdata bundle for you.

Bundling the Data into the App

The first thing I need to do is get the data into itself, so that it’s transferred to the simulator. In a perfect world this would be as easy as adding the bundle to my “Copy Bundle Resources” build phase.

Copy Bundle Resources build phase from Xcode, with the UITests.xcappdata file included.

However, the trick is that I only want to do this when I’m actually running my screenshot scripts: I don’t want to affect my regular debug builds, and I definitely don’t want to push any of this to the App Store. That means the “Copy Bundle Resources” route isn’t the way to go. There is a couple of ways I can take this into consideration though… and since I like me some fallbacks, I’m going to take both approaches.

I have two configurations set up for GIFwrapped: Debug and Release. Obviously, the latter is used for Testflight and App Store builds, while the former is my everyday debugging configuration. So if I restrict my copy script by checking the value of my CONFIGURATION environment variable, I should be golden.

if [[ ${CONFIGURATION} == "Debug" ]]; then
    cp -R "${SRCROOT}/AppData/UITests.xcappdata" $bundlePath

This is a good start, but because I use fastlane’s snapshot tool to do my screenshots, I do have another option at my disposal. The builds for snapshot include the FASTLANE_SNAPSHOT environment variable, which I can detect to restrict to just the builds being done for my screenshots.

if [[ ${CONFIGURATION} == "Debug" ]] && [[ ! -z ${FASTLANE_SNAPSHOT} ]]; then
    cp -R "${SRCROOT}/AppData/UITests.xcappdata" $bundlePath

For those new to bash, the ! -z part is just checking whether FASTLANE_SNAPSHOT is defined, since we don’t really need to worry too much about the actual value (it doesn’t exist for other builds). It basically reads “not is not defined”, which is ridiculous… but it gets the job done.

So by dropping my resulting script into a run script build phase, I now have UITests.xcappdata being copied into the app bundle, and thus getting included when GIFwrapped is installed into the simulator for the screenshots. Whoooa, we’re halfway there!

Run script build phase from Xcode, showing the script configured to copy UITests.xcappdata into the app bundle.

Loading the Bundled Data

In case it’s not already obvious, the second part of this process is to actually load the files I’ve bundled into place. Right now, the app bundle contains my xcappdata, but that doesn’t do any good if it’s not where GIFwrapped expect things to be, i.e. the app’s Library or Documents directories. However, it can access the files within the bundle, specifically the “AppData” directory inside UITests.xcappdata, which will allow us to do what we need to.

let bundleURL = Bundle.main.url(forResource: "UITests", withExtension: "xcappdata")
let contentsURL = bundleURL?.appendingPathComponent("AppData")

Because the contents of this directory reflect the structure of GIFwrapped’s directory on device, it’s as simple as enumerating through each file and copying it into place. The easiest way to do this is with a FileManager.DirectoryEnumerator, which can automatically traverse into subdirectories, reducing the headaches a little.

First, I create an enumerator for the contents directory, that automatically pulls the directory flag for each file and delves into subdirectories recursively (which is actually the default behaviour, there’s the .skipsSubdirectoryDescendants for turning that off if you wanted to do that).

let enumerator = FileManager.default.enumerator(at: contentsURL, includingPropertiesForKeys: [.isDirectoryKey], options: [], errorHandler: nil)

Once I have that, I can just iterate through the files one at a time, and skip the directories, because those’ll get created as part of the copy process.

while let sourceUrl = enumerator.nextObject() as? URL {
    guard let resourceValues = try? sourceUrl.resourceValues(forKeys: [.isDirectoryKey]),
          let isDirectory = resourceValues.isDirectory,
          !isDirectory else { continue }
    // And here is where we need to actually handle the file.

Actually copying them comes next, and first of all, this involves figuring out the new location for each of the files. Because the AppData directory (our contentsURL) represents the parent of the Documents and Library directories, I just make it easier by figuring out the path for that directory.

let destinationRoot = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).last?.deletingLastPathComponent()

Then I can perform a little hocus pocus on the sourceUrl to get a destination for each file, and copy it into place. I’ll also make an cursory attempt at creating any missing directories, so I don’t end up failing to copy any files across.

let path = sourceURL.standardizedFileURL.path.replacingOccurrences(of: contentsURL.standardizedFileURL.path, with: "")
let destinationURL = destinationRoot.appendingPathComponent(path)

do {
    try FileManager.default.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
    try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
catch {}

With everything in place, the code will now traverse the important contents of the UITests.xcappdata bundle and copy the files into their appropriate location. That means I can have preloaded contents, a collection of UserDefaults, even preload my IAPs (with a little extra finagling), and the app launches as if it was always like that.

Just one less thing for me to do by hand.