Swift Scripting in the Wild

Swift scripting is awesome. Third party libraries that include references to APIs requiring usage descriptions, without disclosing that information to their users, are less awesome. I recently experienced this very situation and thought it would be a good excuse to use swift scripting to address my problem. (If you aren’t interested in reading my rant and just want to get to the fun of scripting, click here now). 

I would like to begin by stating the obvious:

Know what the third party libraries you are using actually do before you begin to use them.

That being said, sometimes you have no choice but to depend on services that do not expose the inner workings of their frameworks. This happened to me last week. After uploading an update to the app store, our build entered the “Processing” state in iTunes Connect.  Once processing finished, I received the dreaded “iTunes Connect: Your app “App Name“ (Apple ID: xxxxxxxxx) has one or more issues” email. 

The reason? 

Missing Info.plist key – This app attempts to access privacy-sensitive data without a usage description. The app’s Info.plist must contain an NSPhotoLibraryUsageDescription key with a string value explaining to the user how the app uses this data.

Hmmm…

The app does not request photos.  Why am I getting this error? After a quick Google search, I came across a change in iOS 10 that requires apps to include this description if third party libraries include any references that would trigger user permissions (even if the app does not actively use that functionality). 

The problem was it was not obvious which third party library would request photos from the user.  I saw a couple of forums that indicated some ad frameworks triggered similar rejections, but we were not using any of them in the app.  I then found the list of the APIs that would require the NSPhotoLibraryUsageDescription flag in Apple Docs.

In case you are wondering, here is that list:

UIImagePickerController or PHPhotoLibrary requestAuthorization: used in conjunction with any of the following classes: PHAsset PHAssetCollection PHCollection PHCollectionList

Again I confirmed, no references in the codebase.  So, what’s the next step? I could open up the frameworks and libraries in a text editor, but where is the fun in that? 

It’s in that moment that swift scripting came to the rescue!

Swift Scripting

There are some great resources to quickly get started using swift for scripting.  Here is one from a fellow iOS developer on Github that will get you up and running quickly. Now, returning to my rant, you’ll remember I needed to create a script that I can pass a framework path to and have it tell me whether or not it includes any references to the photos APIs listed above. In the six-step process that follows, I will show you how I created a script called tattle to do just that.

Step 1: Create the swift script

$ echo '#!/usr/bin/env xcrun swift' > tattle.swift
$ chmod +x tattle.swift

 

Step 2: Imports and variables

In tattle.swift we will use FileManager, so we need to import Foundation.  Arguments from the command line will be used as paths to lib/frameworks. A dictionary that contains the Info.plist key (key) with a list of references that require that key will be used to find any references in the args we pass to our script. We will now add the following lines of code to tattle.swift:

 

import Foundation

let arguments = Array(CommandLine.arguments.dropFirst())
let manager = FileManager.default
let descriptions = [
    "NSPhotoLibraryUsageDescription": [
        "UIImagePickerController",
        "PHPhotoLibrary",
        "PHAsset",
        "PHAssetCollection",
        "PHCollection",
        "PHCollectionList"
    ]
]

 

Note: The descriptions can be extended to include other descriptions, but I am limiting it to NSPhotoLibraryUsageDescription in this example.

 

Step 3: Checking for References

The function below loops through the descriptions and references those defined in step 2.  If the reference is found, we simply print the reference and the description required.  This will output to the Terminal console when we run the script.

func checkFileForReferences(at path: String) {
    guard let contents = try? String(contentsOfFile: path, encoding: String.Encoding.ascii) else {
        print("Error: Failed to get contents of file: \(path).")
        return
    }
    
    var referenceCount = 0
    
    print("-------\nChecking: \(path)\n")
    
    for (description, references) in descriptions {
        for reference in references {
            if contents.contains(reference) {
               referenceCount += 1
                print("\(reference) referenced. \(description) required.")
            }
        }
    }
    
    if referenceCount == 0 {
        print("OK")
    }
    
    print("-------")
}

 

Step 4: Framework and Static Library support

Since we want to use the same script for both frameworks and static libraries, we need a helper to make sure we are checking the correct file for references (specifically for frameworks).  We can use the following function to handle this:

func checkFileOrFrameworkForReferences(at path: String) {
    let url = URL(fileURLWithPath: path)
    let isValidFile: Bool
    let isFramework = url.pathExtension == "framework"
    var isDirectory: ObjCBool = ObjCBool(false)
    
    manager.fileExists(atPath: "/", isDirectory: &isDirectory)
    
    isValidFile = url.pathExtension == "a" || url.pathExtension == "" && !isDirectory.boolValue
    
    if isValidFile {
        checkFileForReferences(at: path)
    } else if isFramework {
        let name = url.lastPathComponent.replacingOccurrences(of: ".framework", with: "")
        let filePath = "\(path)/\(name)"
        
        checkFileForReferences(at: filePath)
    } else {
        print("Unsupported file.")
    }
}

 

If we are passing the lib (.a) or the framework executable, we will pass the path directly into checkFileForReferences(at:).  If the path is a framework, we append the executable to the path.

Step 5: Using Args

for path in arguments {
    checkFileOrFrameworkForReferences(at: path)
}

 

The final snippet grabs paths from the arguments (see Step 2) and passes them into checkFileOrFrameworkForReferences(at:).

 

Step 6: Running the Script

$ ./tattle.swift /path/to/some.framework
-------
Checking: /path/to/some.framework/some

UIImagePickerController referenced. NSPhotoLibraryUsageDescription required.

——-

 

Conclusion

As you can see, scripting in swift is indeed awesome. As an iOS/Mac developer, I was able to leverage existing knowledge of core frameworks to solve my problem quickly. It’s solving issues like these that make me excited for future swift development. I may even be back soon with another blog post investigating server-side swift. 

You can copy and paste the code for tattle.swift here:

#!/usr/bin/env xcrun swift

import Foundation

let arguments = Array(CommandLine.arguments.dropFirst())
let manager = FileManager.default
let descriptions = [
    "NSPhotoLibraryUsageDescription": [
        "UIImagePickerController",
        "PHPhotoLibrary",
        "PHAsset",
        "PHAssetCollection",
        "PHCollection",
        "PHCollectionList"
    ]
]

func checkFileForReferences(at path: String) {
    guard let contents = try? String(contentsOfFile: path, encoding: String.Encoding.ascii) else {
        print("Error: Failed to get contents of file: \(path).")
        return
    }
    
    var foundReferences = 0
    
    print("-------\nChecking: \(path)\n")
    
    for (description, references) in descriptions {
        for reference in references {
            if contents.contains(reference) {
                foundReferences += 1
                print("\(reference) referenced. \(description) required.")
            }
        }
    }
    
    if foundReferences == 0 {
        print("OK")
    }
    
    print("-------")
}

func checkFileOrFrameworkForReferences(at path: String) {
    let url = URL(fileURLWithPath: path)
    let isValidFile: Bool
    let isFramework = url.pathExtension == "framework"
    var isDirectory: ObjCBool = ObjCBool(false)
    
    manager.fileExists(atPath: "/", isDirectory: &isDirectory)
    
    isValidFile = url.pathExtension == "a" || url.pathExtension == "" && !isDirectory.boolValue
    
    if isValidFile {
        checkFileForReferences(at: path)
    } else if isFramework {
        let name = url.lastPathComponent.replacingOccurrences(of: ".framework", with: "")
        let filePath = "\(path)/\(name)"
        
        checkFileForReferences(at: filePath)
    } else {
        print("Unsupported file.")
    }
}

for path in arguments {
    checkFileOrFrameworkForReferences(at: path)
}


Subscribe to our Blog

Kyle Balogh
Kyle Balogh
Kyle is a mobile software engineer with experience in iOS and Android dating back to 2008. He has also dabbled in the world of automated test tools. When not spending time with his family he is learning new technologies to solve interesting problems.

Deliver off-the-chart results.

WordPress Video Lightbox Plugin