Swift, Plist and Two Smoking Scripts

Published Apr 09, 2018Last updated Apr 24, 2018
Swift, Plist and Two Smoking Scripts

Starting with iOS 10 developers should provide descriptions for using user private data otherwise apps will crash. But if you leave a value field empty, everything will work until you send a new build to iTunes Connect. The build disappears and you (or your client) get an email about it:

Dear developer,
We have discovered one or more issues with your recent delivery for “Your app”. To process your delivery, the following issues must be corrected:
Missing Info.plist key  — This app attempts to access privacy-sensitive data without a usage description. The app’s Info.plist must contain an NSCalendarsUsageDescription key with a string value explaining to the user how the app uses this data.
Once these issues have been corrected, you can then redeliver the corrected binary.
Regards,
The App Store team

There are at least three ways to check it:

  • Check manually. Definitely, no.
  • Write fastlane lane. It’s a good approach if you already use fastlane and know Ruby. Fastlane supports Swift as well but with having additional Xcode project.
  • Write Build Phase script. That’s my bro!

There are a lot of good Build Phase examples: linting code with SwiftLint, copying resources for CocoaPods frameworks etc. Most of them written in Bash. But wait, what about Swift? Can I use Swift for scripting?
ezgif.com-gif-maker.gif

Preparation

One of the simplest implementations of our task is to check that Swift files contain classes from system frameworks. Imports are not a guarantee that there is related logic, because we can forget to remove unused ones.

Let’s open a project target and select Build Phases tab. I don’t like to write scripts here for the following reasons:

  • Bad diffs. It’s hard to read code in one line:


Diff view in Fork app

Unless you’re a lucky owner of this device:


Photo by T045TBR0T on Twitter

  • No syntax highlighting and autocompletion
  • Can’t reuse the script for multiple targets. You have to copy-paste script code to every target and make changes in every copy.

Let’s create a separate file for our script:

mkdir Scriptstouch Scripts/usage_description_check.swift

Don’t forget to make the script executable by changing its permission:

chmod +x Scripts/usage_description_check.swift

Next, add a Build Phase script with a path to our script:

Note: Check “Run script only when installing” flag to run it only during Archive action.

Our journey has started! 🚀

Script logic

Add this line at the top of the script file:

#!/usr/bin/env xcrun --sdk macosx swift

The line launches Swift REPL (Read-Eval-Print-Loop) first and the rest of our script actually compiles in Swift environment. Pay attention to --sdk option. By defaultxcrun gets SDK from SDKROOT environment variable (remember this guy, we will return to it a bit later). We should enforce xcrun to use macOS SDK.

Next, we should get a project directory and product settings from Info.plist. We can use environment variables for it via getenv:

import Foundation

// Get project URL
guard let projectDirRawValue = getenv("PROJECT_DIR"),
    let projectDirectoryPath = String(utf8String: projectDirRawValue) else {
        exit(1)
}
let projectURL = URL(fileURLWithPath: projectDirectoryPath)
// Get Info.plist path
guard let productSettingsPathRawValue = getenv("PRODUCT_SETTINGS_PATH"),
    let productSettingsPath = String(utf8String: productSettingsPathRawValue) else {
        exit(1)
}

Note: To get all environment variables, just enable Show environment variables in build log under the Build Phase.

Get all .swift file URLs:

guard let enumerator = FileManager.default.enumerator(at: projectURL, includingPropertiesForKeys: nil) else {
    exit(1)
}
var swiftFileURLs = enumerator.allObjects
    .compactMap { $0 as? URL }
    .filter { $0.pathExtension == "swift" }

Note :compactMap is a replacement of flatMap in Swift 4.1. If you use previous Swift versions, just replace it with flatMap.

Next, we should add patterns for descriptions. As described above, we will find classes from system frameworks.

let patterns = ["EKEventStore": "NSCalendarsUsageDescription"]

After that we enumerate Swift files and get all the required keys for used patterns:

var keys: [String] = []
for url in swiftFileURLs {
    let contents = try String(contentsOf: url, encoding: .utf8)
    for (pattern, key) in patterns {
        if contents.contains(pattern) {
            keys.append(key)
        }
    }
}

Next we should check that the product settings contain required keys and related non-empty descriptions:

guard let productSettings = NSDictionary(contentsOfFile: productSettingsPath) else {
    exit(1)
}
for key in keys {
    guard let value = productSettings[key] as? String else {
        // Missing key
        exit(1)
    }
    if value.isEmpty {
        // Empty description
        exit(1)
    }
}

Nice! But it’s not enough. How to show Xcode warnings and errors from scripts? That’s very easy! You only need to add warning: or error: before your print message and Xcode will automatically annotate it as a warning or an error accordingly:

print("error: Error message")

Let’s replace our comments with the correct error messages:

// Missing keyprint("error: Missing \(key)")...// Empty descriptionprint("error: Empty description for \(key)")


We should be more careful…

Conclusion

Today we have learned how to use Swift for Build Phase scripts. I hope you like the tips in the article. If you have questions related to the topic, feel free to ask it in comments here or on Twitter @iosartem. Whole case project with a full script code is available on Github.

Thanks for reading!

Discover and read more posts from Artem Novichkov
get started