Building Command-Line Tools with Swift

I have a number of iOS apps that require certain content to be generated before being bundled in the app. For example:

  • In Streaks, all of the icons need to be made available in a number of sizes. When providing images for watchOS 2 extensions and/or complications, you cannot generate them at runtime - they must be pre-packaged.
  • In Streaks and Streaks Workout, I have a number of translated strings that need to be merged into a .stringsdict plist file. I receive the translated strings in a standard .strings file, but in order to display plurals correctly across all languages, they must be converted to a .stringsdict.

In both of these cases, I've written command-line Swift tools then added them as a new build phase for the respective iOS target.

Adding the Tool To Your Workflow

The process is as follows:

  1. Add a new target to your project.
  2. Under OS X, select Command Line Tool.
  3. Write your code in the newly-created main.swift (more on this below).
  4. Build and archive. You'll be left with an executable binary file. Copy this somewhere into your project hierarchy (such as /path/to/project/bin/YourBinary).
  5. Select Build Phases for your iOS target.
  6. Add a new phase of type New Run Script Phase.
  7. Enter the following as the script (adjust the arguments as you see fit):
"${PROJECT_DIR}/bin/YourBinary" [argument_1] [argument_2]

Writing the main.swift File

For the most part, this involves writing Swift as you would with your iOS project.

If you've followed the instructions in this article, you will have access to the classes from your iOS project also (You will need to add the command-line tool target to each of them, however).

The main things that you'll need to know are:

  • Processing arguments
  • Writing to stdout / stderr
  • Exit codes

Processing Arguments

In Swift, you can access command line arguments using Process.arguments, which is of type [String] (a static string array).

The path used to run the binary is in Process.arguments[0], while subsequent arguments are in the remaining array elements.

For example, if you're expecting a single string argument, you could use the following to check for it:

if Process.arguments.count < 2 {  
    // Expecting a string but didn't receive it
}
else {  
    let arg = process.arguments[1]
}

Writing to stdout / stderr

You can access the file handles for stderr or stdout as follows:

let stderr = NSFileHandle.fileHandleWithStandardError()  
let stdout = NSFileHandle.fileHandleWithStandardOutput()  

You can then write to each handle as required using writeData(). Since this method requires an argument of NSData, you'll need to first encode it. For example:

if let data = str.dataUsingEncoding(NSUTF8StringEncoding) {  
    stderr.writeData(data)
}

Realistically, if you're building a command-line tool for use in your iOS project, you will only need stderr, since Xcode will report the output from stderr if your tool reports failure (see the next section on Exit Codes).

To help with this, I use a simple wrapper to convert a String to NSData:

func writeToStdError(str: String) {  
    let handle = NSFileHandle.fileHandleWithStandardError()

    if let data = str.dataUsingEncoding(NSUTF8StringEncoding) {
        handle.writeData(data)
    }
}

This way you can improve upon handling of arguments (or use it for handling other errors):

if Process.arguments.count < 2 {  
    // Expecting a string but didn't receive it
    writeToStdError("Expecting string argument!\n")
    writeToStdError("Usage: \(Process.arguments[0]) [string]\n")
}
else {  
    let arg = process.arguments[1]

    // Do stuff...
}

Exit Codes

An exit code indicates whether or not a program ran successfully or not. For example, if your command-line tool needs an input file, an error exit code might be given if the input file does not exist.

When your command-line tool is run by Xcode in your iOS project's build phase, the build will fail if an exit code is received. Conversely, a success error code means that phase of the build will succeed.

To exit successfully:

exit(EXIT_SUCCESS)  

To indicate failure:

exit(EXIT_FAILURE)  

These appear at the end of your script (or more accurately: no more statements are executed after exit() is called).

Here's a longer example:

if Process.arguments.count < 2 {  
    exit(EXIT_FAILURE)
}

let path = Process.arguments[1]  
let url = NSURL.fileURLWithPath(path)

// Check if the file exists, exit if not
if !baseUrl.checkResourceIsReachableAndReturnError(&error) {  
    exit(EXIT_FAILURE)
}

// File exists, now do stuff

// Finally, exit
exit(EXIT_SUCCESS)  

That's all there is to it. Now go, program (and eat bagels).