Writing command line interfaces for iOS apps

Mar 1 2020 6:00 PM

Writing automated tests like unit, integration, or UI tests can be a great way to have reproducible steps that ensure an app is working the way we expect it to. But there are some circumstances where automated testing just doesn’t cut it.

Some behaviors of mobile apps have subtleties that can only be assessed by holding a device in your hand — just like your users do — and seeing how things behave. When something is not working as expected, repeatedly testing things by hand or going back to a known initial state for debugging can be a pain.

There are countless ways to go about creating a better environment for debugging and iteration while working in iOS apps, such as using launch arguments, environment variables, or having an internal settings or debug menu inside the app itself where you can tweak things. I believe every shipping app should include those, since they improve the development process significantly.

ChibiStudio’s internal settings

But even with all of those options available, I still think there’s room for one more: a command line interface. Yes, you read it correctly: I wrote a command line interface for my iOS app.

chibictl

Since the app is called ChibiStudio, I decided to call this command line tool chibictl (it’s pronounced “chee bee cee tee el”, Erica).

Watch the video below for a look at some of the things this tool can do (so far) and read on for the technical bits on how it works.

How is it made?

We can’t write or run command line tools on regular iOS devices (yet, FB7555034). Besides, the whole point of having a command line interface for an iOS app would be to run it from your Mac so you don’t need to interact with the device itself.

Thus, there needs to be a way to send data back and forth between a Mac and iOS devices (or the Simulator). There’s probably some way to do it using the wired lightning connection, we could also spin up a socket or HTTP server on the device, but I decided to use the MultipeerConnectivity framework.

The framework allows iOS, Mac and tvOS — no watchOS yet, sorry — devices to talk to other nearby devices over WiFi, peer-to-peer WiFi or Bluetooth. The cool thing about it is that the underlying communication is abstracted for you, so there’s no need to worry about the low-level networking bits.

Even though MultipeerConnectivity provides a somewhat high-level API, in my previous experiences using it, I noticed that there tends to be quite a bit of boilerplate involved in getting it all up and running. For that reason, I decided to create the MultipeerKit library, which lets you set up communication between devices very easily, like so:

// Create a transceiver (make sure you store it somewhere, like a property)
let transceiver = MultipeerTransceiver()

// Start it up!
transceiver.resume()

// Configure message receivers
transceiver.receive(SomeCodableThing.self) { payload in
	print("Got my thing! \(payload)")
}

// Broadcast message to peers
let payload = SomeEncodableThing()
transceiver.broadcast(payload)

My favorite thing about it is that it allows you to send and receive anything that conforms to the Codable protocol, registering a specific closure to be called for each type of entity you want to transfer between peers.

Practical example: app side

Listr

The code snippets below are from the Listr sample app. It’s a very simple to-do app written in SwiftUI, which includes a CLI tool for interacting with its data.

Since it’s easy to exchange data between a Mac and iOS devices or the Simulator using MultipeerKit, I decided to represent each possible command the CLI can send as a struct.

Here’s an example, showing the struct that represents the “add item” command:

struct AddItemCommand: Hashable, Codable {
    let title: String
}

This is a very simple command with just a single property — the title of the list item to be added. There are some commands which don’t even require any arguments, but are defined as structs conforming to the Codable protocol just so that I can receive them with MultipeerTransceiver.

An example of such a command that takes no input is the ListItemsCommand:

struct ListItemsCommand: Hashable, Codable { }

In order to respond to these commands being sent by the command line tool, I’ve implemented a CLIReceiver, which registers a MultipeerTransceiver with the service type listrctl (the name of the CLI tool).

With the transceiver in place, I can then register handlers for each command the app supports:

final class CLIReceiver {

    // ...
    
    func start() {
        transceiver.receive(AddItemCommand.self, using: response(handleAddItem))
        transceiver.receive(ListItemsCommand.self, using: response(handleListItems))
        transceiver.receive(DumpDatabaseCommand.self, using: response(handleDumpDatabase))
        transceiver.receive(ReplaceDatabaseCommand.self, using: response(handleReplaceDatabase))

        transceiver.resume()
    }

}

Notice how each command handling function is wrapped by a response function. That’s how the receiver can send data back to the command line tool. In order to do that, I defined another model, CLIResponse, which is actually an enum:

enum CLIResponse: Hashable {
    case message(String)
    case data(Data)
}

Why an enum? I didn’t want each command to have a specific response type, since that would complicate the implementation significantly. What I figured out was that I really only needed two types of responses: a message describing what happened or returning human-readable content for the CLI user, or some binary data that the CLI will then write to a file. So that’s why I decided to go with an enum with associated values: String or Data.

Making this enum conform to the Codable protocol requires custom init(from decoder: Decoder) and encode(to encoder: Encoder) implementations, which are quite simple, but I won’t include them here for brevity — check the sample app for the full implementation.

Back to that response wrapper, this is what it looks like:

private func response<T: Codable>(_ handler: @escaping (T) -> CLIResponse) -> (T) -> Void {
    return { [weak self] (command: T) in
        let result = handler(command)

        self?.transceiver.broadcast(result)
    }
}

All it does is call the handler function that was passed in, which returns a CLIResponse. It then broadcasts that response to all connected peers — in this case, just the device running the command line tool. This was the best way I found to implement handlers as functions that take the specific command struct as input and return a CLIResponse as an output, leaving the multipeer communication to the receiver itself, rather than having to repeat the broadcast call everywhere.

How each command handler itself is implement is completely dependent on the app itself. Here’s an example of how I’ve implemented the “list items” command handler:

private func handleListItems(_ command: ListItemsCommand) -> CLIResponse {
    let list = app.store.items.map { item in
        "\(item.done ? "[✓]" : "[ ]" ) \(item.title)"
    }.joined(separator: "\n")

    return .message(list)
}

In that context, app is just a computed property that returns the AppDelegate, which in turn has a property of its own that’s the data store used by the app. Accessing the app delegate like this is not necessarily a good practice in app code, but since this is an internal, debug-only feature, I didn’t think it was that big an issue.

Speaking of internal and debug-only, all code related to the CLI tool that’s included in the app is between #if DEBUG and #endif, to ensure it’s never included in the app when being uploaded to the App Store. Something else I do in some of my apps is to have a separate Internal configuration, which allows me to upload internal versions of the app — with all debugging tools included — to TestFlight, for internal testers.

Practical example: CLI side

That’s an overview of how the command receiver was implemented in the app. Now let’s see how the command line tool itself can be implemented. The first step is to add a new target to the Xcode project, selecting macOS > Command Line Tool from the new target sheet when prompted. I called the CLI listrctl.

Creating listrctl target

All command models must be included as part of both the iOS app target and the CLI target, since both will need to use them. Also, MultipeerKit must be included in the CLI’s “Frameworks & Libraries”.

Since this is a command line tool, one of its tasks will be to parse arguments passed to it. Luckily, Apple has recently released the ArgumentParser library, which greatly simplifies that task, so I decided to use it for the command line tool.

Just like the app has a CLIReceiver, the command line tool has a CLITransmitter — technically, they’re both transceivers, since they can both send and receive data. The transmitter is responsible for sending the commands to nearby iOS devices or Simulator instances, and receiving the CLIResponse structs sent by the iOS app.

There’s a problem, though: I’m writing a command line tool, which is inherently synchronous — it runs until it gets results back, then stops — but at the same time I’m dealing with MultipeerConnectivity through MultipeerKit, a highly asynchronous process.

That means the transmitter has to wait until it sees a connected device, send the command, wait for a reply to come back, then finally show the results to the CLI user and terminate its process.

Here’s how I’m achieving that:

final class CLITransmitter {

    static let current = CLITransmitter()

    static let serviceType = "listrctl"

    private lazy var transceiver: MultipeerTransceiver = {
        var config = MultipeerConfiguration.default

        config.serviceType = Self.serviceType

        return MultipeerTransceiver(configuration: config)
    }()

    var outputPath: String!

    func start() {
        transceiver.receive(CLIResponse.self) { [weak self] command in
            guard let self = self else { return }

            switch command {
            case .message(let message):
                print(message)
            case .data(let data):
                self.handleDataReceived(data)
            }

            exit(0)
        }

        transceiver.resume()
    }

    private func handleDataReceived(_ data: Data) {
        try! data.write(to: URL(fileURLWithPath: outputPath))
        outputPath = nil
    }

    private let queue = DispatchQueue(label: "CLITransmitter")

    private func requirementsMet(with peers: [Peer]) -> Bool {
        !peers.filter({ $0.isConnected }).isEmpty
    }

    func send<T: Encodable>(_ command: T) {
        queue.async {
            let sema = DispatchSemaphore(value: 0)

            self.transceiver.availablePeersDidChange = { peers in
                guard self.requirementsMet(with: peers) else { return }

                sema.signal()
            }

            _ = sema.wait(timeout: .now() + 20)

            DispatchQueue.main.async {
                self.transceiver.broadcast(command)
            }
        }

        CFRunLoopRun()
    }

}

As you can see in the start method, the transmitter registers a receiver for the CLIResponse type. When a response comes in, if it’s just a String, it’s just printed to the console, if it’s Data, it’s written to the filesystem location set by the path property. After that’s done, the process is terminated successfully by calling exit(0).

The send method is the most interesting one. It immediately dispatches to a separate queue, then it sets up a semaphore to wait in that queue until a device that meets the criteria is found. In the sample app, any remote device that’s currently connected to the device running the CLI meets the criteria. Once the criteria is met, it then broadcasts the command to all connected devices. It’s worth noting that MultipeerKit by default will automatically establish a connection to nearby peers, so I don’t have to do any of that manually.

The CFRunLoopRun() call after that just keeps the main runloop active, which is necessary for all of the multipeer machinery to do its job.

The main.swift file in listrctl is where commands are defined, using the API provided by ArgumentParser. Here’s an excerpt:

CLITransmitter.current.start()

struct ListrCTL: ParsableCommand {
    
    static let configuration = CommandConfiguration(
        commandName: "listrctl",
        abstract: "Interfaces with Listr running on a device or simulator.",
        subcommands: [
            Item.self,
            Store.self
    ])

    // ...

    struct Store: ParsableCommand {

        static let configuration = CommandConfiguration(
            commandName: "store",
            abstract: "Manipulate the data store.",
            subcommands: [
                Dump.self,
                Replace.self
            ]
        )

        struct Dump: ParsableCommand {

            static let configuration = CommandConfiguration(
                commandName: "dump",
                abstract: "Dumps the contents of the store as a property list."
            )

            @Argument(help: "The output path.")
            var path: String

            func run() throws {
                CLITransmitter.current.outputPath = path

                send(DumpDatabaseCommand())
            }

        }

        // ...
    }
}

ListrCTL.main()

This part of the CLI almost feels declarative, since it’s just defining the commands that can be invoked, and they defer all major work to CLITransmitter.

I can call send from within the command’s run method because of this extension I made:

extension ParsableCommand {
    func send<T: Encodable>(_ command: T) {
        CLITransmitter.current.send(command)
    }
}

Here's the result:

Conclusion

That’s it! I know writing command line interfaces for iOS apps can seem like a crazy idea, but I encourage you to try it out and see how it can improve your development and testing workflow. Try out the sample app and explore the code in more detail.