Building a better console replacement: an exercise in Xcode 2

In my quest to port my little AppleScript app LogLogger to support the browsing of historic log data to Swift, started here, I have been adding features to enable it to obtain log entries for Time Machine. This has revealed the differences between trivial examples and the real world.

Last time, I reached the stage where my code was generating the command to return an excerpt from the logs listing Time Machine entries, of the form
log show --predicate 'subsystem == "com.apple.TimeMachine"' --style syslog --info --last 2h | cut -c 1-22,43-999 > /Users/hoakley/Documents/0newDownloads/LogLoggerOut00.text

In AppleScript, having built that as a string, it was simple to execute that command using
set theVer to do shell script theAppCmd

What I wanted to do now was equip my simple dialog window with a view to display the output resulting from the command, and execute that command from within my application. I had thought that both those steps were well documented, but as it turns out, I was unable to find working Swift 3 code to perform either.

For the moment, I want to inspect the command which is going to be executed before sending it for execution. So I changed the title of the existing button to Set command, and added a new button Run command to take that string and execute it. I then added a Text View to contain the standard output of the command; as this could amount to many hundreds of lines of text, I wanted a scrolling text view from which the user can copy, but not edit, content, for which a Text View seemed ideal.

consolation01

I tidied the control outlets to the top of my source, with the addition of my new views:

@IBOutlet weak var textTimePeriod: NSTextField!
@IBOutlet weak var popupTimeUnits: NSPopUpButton!
@IBOutlet weak var theCmdOut: NSTextField!
@IBOutlet weak var theResultText: NSScrollView!
@IBOutlet var theResTxt: NSTextView!

I then have two actions, one for each of the buttons. The Set command button needs to assemble and display the command string, and Run command then executes the command, obtains the piped output, and inserts that into theResTxt.

Here was the first problem: how to insert the text returned into that Text View. I looked around and tried several code snippets posted on the internet, but although some were claimed to be in Swift 3 for Cocoa, none worked. For the moment, I’ll assume that the text is returned as straight UTF8 text in a variable named data. The first step is to read it as an NSString object, then to convert it to an NSAttributedString as required by the NSTextView into which it will go. Finally, it is appended to the end of the displayed text:
let string = NSString(data: data, encoding: String.Encoding.utf8.rawValue)!
let attr = NSAttributedString(string: string as String)
theResTxt.textStorage?.append(attr)

There may well be a neater method, but that seems to work reliably. In arriving at this solution, I discovered that specifying ranges in Swift and Cocoa is an utter disaster, with several completely incompatible ways of doing it. I kept well clear.

Once I had done that, I tried setting the new Process up and executing it, along the lines of
let task = Process()
task.launchPath = "/usr/bin/log"
task.arguments = [theFullCmdStr]
let outPipe = Pipe()
task.standardOutput = outPipe
task.launch()
let fileHandle = outPipe.fileHandleForReading
let data = fileHandle.readDataToEndOfFile()
task.waitUntilExit()

That code first creates the new Process without launching it. It sets up the launchPath to the command, sets the arguments to be the one long string
show --predicate 'subsystem == "com.apple.TimeMachine"' --style syslog --info --last 2h
It then sets up an output pipe into which the standard output will be passed, launches the Process, obtains the data, and waits for the command to complete. Although this worked for trivial examples, it failed every time with log show, and no amount of coaxing and tweaking produced any solution.

consolation02

This was an excellent time to go back to a Swift playground, where I discovered that passing the whole argument string failed consistently. I cannot find this mentioned anywhere in any documentation; indeed, I cannot find any informative documentation about running Process objects like this, and all the examples that I can find are trivial relative to my case.

After considerable experimentation in Swift playgrounds, I discovered that the log command (at least) will only accept its arguments when loaded as an array of argument strings, in task.arguments, each string consisting of a single argument. This is also true if you use the more succinct Process.launchedProcess(launchPath: , arguments: ), which I cannot use here because it creates and launches the Process and does not permit piping.

So my Action function for the Set command button now reads:
@IBAction func btnGetLog(_ sender: Any) {
theTimeSelStr = popupTimeUnits.titleOfSelectedItem!
theTimeLet = String(theTimeSelStr[theTimeSelStr.startIndex])
let theTimePCons = String(theTimePeriod) + theTimeLet
theFullCmdStr = theCommandStr1
theFullCmdStr.append(theTimePCons)
theCmdOut.stringValue = String(describing: theFullCmdStr)
}

This converts the integer value of theTimePeriod to a string, and appends to that the initial letter of the unit of time as set in the popup menu. It then appends that string to the array of arguments, using append(). Although the Swift 3 documentation says that += is “an alternative”, it is not one that Xcode was prepared to allow here (also undocumented). The final line then displays the array of arguments in the text box for checking.

consolation03

The Action function for the Run command button then executes the command and inserts the piped standard output into my scrolling Text View:
@IBAction func btnRunCmd(_ sender: Any) {
let task = Process()
task.launchPath = "/usr/bin/log"
task.arguments = theFullCmdStr
let outPipe = Pipe()
task.standardOutput = outPipe
task.launch()
let fileHandle = outPipe.fileHandleForReading
let data = fileHandle.readDataToEndOfFile()
task.waitUntilExit()
let status = task.terminationStatus
if status != 0 {
// handle errors
}
else {
let string = NSString(data: data, encoding: String.Encoding.utf8.rawValue)!
let attr = NSAttributedString(string: string as String)
theResTxt.textStorage?.append(attr)
}
}

consolation04

But this still didn’t work, reporting consistently that the predicate (specifying log entries for Time Code) was malformed, in other words there was something flawed in the argument that I was passing
--predicate 'subsystem == "com.apple.TimeMachine"'

I had, of course, realised that the quotation marks needed to be ‘escaped’, so was actually putting in the argument string
--predicate \'subsystem == \"com.apple.TimeMachine\"\'
and I fiddled around for a long time with different variants, even inserting direct Unicode characters, without any success. It then occurred to me that I was constructing a predicate, for which there is an NSPredicate class. Using a Swift playground, and the slightly better documentation provided on predicates, I constructed the predicate string using those:
let pred = NSPredicate(format: "%K == 'com.apple.TimeMachine'", argumentArray: ["subsystem"])
let string = pred.predicateFormat

consolation05

Miraculously, that worked because it constructed
subsystem == "com.apple.TimeMachine"
without the single quotation marks. So another lesson is that, when submitting predicates as arguments, they are submitted as an argument, and without the single quotation marks. So my working argument array might be:
["show", "--predicate", "subsystem == \"com.apple.TimeMachine\"", "--style", "syslog", "--info", "--last", "4h"]

consolation06

It would not have taken many lines of documentation to make this clear, but it took me a long time in both my app source and Swift playgrounds to work this out for myself.

consolation07

At the end of all this, I now have a version of my new app, Consolation, which does something useful: it displays historic log entries relating to Time Machine, for a period which you choose.

(I am using the current release of Xcode (8.1), using Swift 3.0.1, and running on Sierra 10.12.1.)