Checking and downloading updates automatically in Swift

I promised yesterday to show how I had implemented the new update checking and downloading mechanism for my apps. This article steps through the source code in Swift 5.

All the code concerned with this is in the AppDelegate, and is called in the function applicationDidFinishLaunching() immediately after integrity checking.

That function contains the interface with preference settings and basic decision-making over whether to check for updates. First it gets three settings from the preferences file. If these keys don’t exist there, it has to handle the ‘nil’ returned values: for Booleans, that’s a false, and for the two doubles that’s 0.0.

If updateCheckInt, the minimum interval between update checks, is less than 1.0, which includes zero values, we set it to the default of 12 hours. Note that rather than lastUpdateCheck storing the time of the last update check, all the time arithmetic is done in intervals since the reference date, i.e. in seconds, for simplicity.

let defaults = UserDefaults.standard
let noUpdateCheck = defaults.bool(forKey: "noUpdateCheck")
let lastUpdateCheck = defaults.double(forKey: "dateLastUpdateCheck")
var updateCheckInt = defaults.double(forKey: "updateCheckInt")
if updateCheckInt < 1.0 {
updateCheckInt = 60.0 * 60.0 * 12.0
}
let now = Date.init().timeIntervalSinceReferenceDate

updatecode01

We call the update check if noUpdateCheck is false, which includes if that value doesn’t exist in the Property List, and if the interval since lastUpdateCheck is greater than updateCheckInt. Whatever happens in the update check, we then set the dateLastUpdateCheck value to the time interval since the reference date, to avoid repeated attempts to check for updates, for example if the Mac is offline for a while.

if !noUpdateCheck {
if ((now - lastUpdateCheck) > updateCheckInt) {
self.checkGetUpdate()
defaults.set(Date.timeIntervalSinceReferenceDate, forKey: "dateLastUpdateCheck")
} }

Finally, we write out the values of noUpdateCheck and updateCheckInt. Although the former hasn’t changed, writing these out ensures that those values exist in the preferences file.

defaults.set(noUpdateCheck, forKey: "noUpdateCheck")
defaults.set(updateCheckInt, forKey: "updateCheckInt")

Now to do the update check and download if needed, in the function checkGetUpdate(). First steps here are to get the CFBundleShortVersionString for this app’s main bundle, which is the version number of the app that is running. We’ll keep this as a string rather than trying to convert it into version numbers as such. This is because, if that and the version string in the reference data are different, we can safely assume that it’s because the app running has a lower version number than is current.

func checkGetUpdate() {
if let theInfoDict = Bundle.main.infoDictionary {
if theInfoDict.count > 0 {
if let theAppVer = theInfoDict["CFBundleShortVersionString"] as! String? {

The next few lines of code are taken from an Apple example of how to download a file without blocking, using a URLSession task, and are widely quoted elsewhere. These should cope fine with a small Property List, which should be downloaded in the twinkling of an eye. Because we’re not reporting errors, it suffices here simply to return in the event of them. You may wish to handle errors in more detail.

In the first line, the URL should be to the raw version of the Property List on your GitHub; I have abbreviated it here.

let url = URL(string: "https://raw.githubusercontent.com/…/myupdates.plist")!
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
return
}

updatecode02

Now we need to check that we’ve got the right type of data, which could be of either MIME type, and then get to work on it.

if let mimeType = httpResponse.mimeType {
if ((mimeType == "text/xml") || (mimeType == "text/plain")) {
let theData = data

Running in the main queue asynchronously (non-blocking), we do the work of processing the Property List to extract its information. Here, I’m using a Property List which consists of an array of small dictionaries, one for each app supported. So after setting some variables, we try turning the text data obtained from the server into an NSArray.

DispatchQueue.main.async {
do {
let thisAppName = "Revisionist" //**** change per app ****
var theNewVer = ""
var theUpdPath = ""
let thePListArr = try PropertyListSerialization.propertyList(from: theData!, options: [], format: nil) as! NSArray

If we have got a non-empty array, iterate through its dictionaries. When we find a dictionary for the current app, that’s the one we need to access to decide whether an update is available.

if thePListArr.count > 0 {
for item in thePListArr {
let theDict = item as! NSDictionary
let theAppName = theDict["AppName"] as! String
if theAppName == thisAppName {

updatecode03

As we’ve found the data for this app, now’s the time to change the Help menu item from reading Update not checked to Checked for update and ticking it, so the user knows that the update info was found successfully.

if let theMenu = NSApplication.shared.helpMenu {
if let theMenuItem = theMenu.item(withTitle: "Update not checked") {
theMenuItem.state = NSControl.StateValue.on
theMenuItem.title = "Checked for update"
} }

Each dictionary has an AppName, the Version, and the URL for the update. With the name matched, we get the other two fields, and check that the URL contains the correct start of the path, which makes it harder for others to hijack.

theNewVer = theDict["Version"] as! String
theUpdPath = theDict["URL"] as! String
if (theNewVer != theAppVer) && (theUpdPath.contains("https://….files.wordpress.com/")) {

We’ve got all the information we need to download the update, so now it’s time to invite the user to choose whether to download the update, or to ignore it, using a simple two-button alert/dialog.

let alert: NSAlert = NSAlert()
alert.messageText = "An update to this app to version " + theNewVer + " is available."
alert.informativeText = "Please choose whether to download the update now in your default browser."
alert.alertStyle = NSAlert.Style.informational
alert.addButton(withTitle: "Ignore")
alert.addButton(withTitle: "Download")

updatecode04

Run the alert, retrieve the result, and depending on which button they clicked on, point the default browser at the URL of the update.

let theResp = alert.runModal()
if theResp == NSApplication.ModalResponse.alertSecondButtonReturn {
let websiteAddress = URL(string: theUpdPath)
NSWorkspace.shared.open(websiteAddress!)

Now close all the code, adding some simple error-handling alerts where they shouldn’t cause thread problems, and wrap up.

} } } } } else {
self.doErrorAlertModal(message: "Update Property List has no entries.")
} } catch {
self.doErrorAlertModal(message: "Error in update Property List.")
} } } } }
task.resume()
} else {
self.doErrorAlertModal(message: "Can't check for updates:\nNo app version string found.")
} } } }

updatecode05

Testing and debugging this isn’t easy because of the complex logic. Using preference settings is helpful, as you can change those at the command line, but cfprefsd certainly can make it very hard to keep a watch on what is going on with them.

Just one more time, here is my attempt to sketch out the conditions built into that code.

UpdateDecisions02

Whether you decide to implement your own version of the above, or just go for Sparkle, I wish you happy coding!