Feb 17, 2020

Auto-updating sandboxed Mac apps without Sparkle

Distributing of software for macOS is quite hard if you are not in the Mac App Store. Configuring Developer ID and Notarization take time but both of these processes are documented. However there is no official way to automatically download and install new versions of our apps on client machines. Please read this fantastic article about “The Sad State of Mac App Distribution” to get a better idea of all the issues. The state of things is so bad that even the first version of Apple’s own “SF Symbols” app was distributed without any update mechanism. How cool is that?

Long time ago — in 1984 maybe — Andy Matuschak released a famous open-source framework named Sparkle. Unfortunately Andy left its development after joining Apple, but his successor Kornel Lesiński does a brilliant job of maintaining this important project.

Sparkle vs Sandbox

Eventually Apple introduced Sandbox for Mac apps, and Authorizarion Services became unavailable. In order to enable automatic software updates, Sparkle needs a privileged helper tool with a root access. The branch named 2.x has got a long history and uses a deprecated API, but it does the job.

Nevertheless I decided to avoid using Sparkle framework for NativeConnect. Their Sandbox implementation is still in Beta and it would add 1 MB of XPC helpers to the bundle.

There is another framework from GitHub named Squirrel, but it is not compatible with Sandbox. It also brings Mantle and ReactiveCocoa libraries as dependencies.

So I wanted to build a custom update engine from the very beginning. This is not easy however, and the app was launched without support for in-app updates. As a temporary workaround, NativeConnect periodically asked the App Center if a newer version is available. Then it added a nice badge to the toolbar, suggesting to download and install an update manually.

The “Shparkle” Project

Once the launch was over, I started the new project. And after weeks in development, a major Update 1.1 was released for public. Among some great features, this new version has finally brought support for automatic in-app updates.

NativeConnect is still sandboxed, but now it includes a primitive non-sandboxed XPC service for command-line tasks of extracting an archive and replacing a bundle in the “Applications” folder. This helper tool is a necessary compromise, and it weights just 100 KB.

I’m not going to open-source “Shparkle” at the moment because such sensitive component surely needs some battle-testing. But in this article I’d like to describe a generic workflow for your consideration. I hope to get some feedback and maybe questions, so feel free to reach me in Twitter.

By the way, if you have an idea for a better name for such framework, I’m all ears. Just let me know!

1. The app is launched

Right after launch, there is no information about a newer version. We could say that the status of in-app updates is unknown, but at least we know the local version number.

Preferences panel before Checking for Updates

This is the initial state of the Updates panel in Preferences.

2. Checking for Updates

NativeConnect is distributed via the App Center and we use their custom JSON feed for updates. This could be your own server however, so for simplicity let’s show it as “Cloud”.

This step is triggered when you hit the main menu item “Check for Updates…” or open the Preferences window for the first time. In our document app, we also check for updates when the current status is requested for a special badge in the toolbar.

Preferences panel while Checking for Updates

Technically we make a URL request and download a JSON or XML feed from the Cloud to retrieve information about the newest available version of the app.

3. No Update is Available

If the local version matches the recent version in the Cloud, we’re up to date. There is no need to show the badge in the toolbar and the command “Check for Updates” becomes available again.

Preferences panel when the local version is up-to-date

Optionally, we may schedule the next automatic check e.g. after 30 minutes.

4. An Update is Available

If the newer version can be downloaded and installed, we display a badge in the toolbar or a local notification. Now we should have a URL of a ZIP archive with an update. So we replace the button “Check for Updates” in Preferences by “Update Now”.

Preferences panel when the newer version is available

Optionally, we may have a checkbox with a setting that allows automatic installation.

5. Downloading a ZIP archive

In this state, a preference panel displays a nice progress indicator, while the update is being downloaded into the temporary location. Downloading can be cancelled if it’s too slow.

Preferences panel while the newer version is being downloaded

When the URLSession asks for a persistent location, we save a ZIP archive to the local folder in Application Support.

6. Requesting help from the XPC service

Next, we pass both the archive and the main bundle paths to the XPC service which is not sandboxed.

Preferences panel while the XPC service is installing a downloaded archive

Important: Our implementation does not use Authorization Services i.e. in-app updates will work only if you are logged in as admin.

Modern macOS is so secured that escalating privileges is a bad practice anyway. If you really want to update the app as a regular user, just click the button “More Info…” to download and install an update manually.

7. Extracting and installing an update

Speaking of errors, any issue in the helper tool is returned back to the main app and displayed in the Preferences panel.

Preferences panel after the XPC service could not extract a downloaded archive

First, the XPC service uses a NSUserUnixTask to unarchive a new version of the app and remove its quarantine flag:

$ unzip "./Updates/NativeConnect-1.1-(11a8d62).zip" -d "./Updates/"
$ xattr -d -r com.apple.quarantine "./Updates/NativeConnect.app"

Next, we compare entitlements of the original bundle versus entitlements of the downloaded app. This could be done in code but the command-line tool named codesign is reliable as well and requires much less work.

$ codesign --display --verbose=2 "/Applications/NativeConnect.app"
$ codesign --display --verbose=2 "./Updates/NativeConnect.app"

Next, we rename the outdated bundle by adding a process identifier as a suffix. Then the up-to-date version of the bundle is moved into the Applications folder.

$ mv "/Applications/NativeConnect.app" "/Applications/NativeConnect (12345).app"
$ mv "./Updates/NativeConnect.app" "/Applications/NativeConnect.app"

Finally, we put the original bundle into the Trash using NSFileManager and remove the temporary folder.

$ rm "/Applications/NativeConnect (12345).app" // Pseudo
$ rm "./Updates"

🎉

8. Relaunching the Mac app

We’re almost done but there’s one neat trick borrowed from the AppMover codebase. A special set of Bash commands allows to schedule one application for launch when another application quits.

$ while /bin/kill -0 12345 >&/dev/null
$   do sleep 0.1
$ done
$ open "/Applications/NativeConnect.app"

While this script is running, we notify the Mac app about the successful update.

Preferences panel after the XPC service successfully replaced the bundle in Applications

At this moment, the client app is still running even though its bundle is in Trash. Time to save all work and quit.

The magic script scheduled by the XPC will automatically launch the updated bundle from the Applications folder. For a better user experience, we pass a flag as a command-line argument. It can be inspected after relaunch to show Release Notes or the Updates panel.

Congratulations, the in-app update is over!

Conclusion

Our implementation is not feature-complete. For example, there is no support for DMG (not sure if it’s necessary for in-app updates). Nor we have a precise progress bar while a ZIP archive is being extracted and moved into the Applications folder. We also prefer command-line tools for some advanced tasks… This solution does work however, and it can be improved in the future.

The process of updating software is quite complex but also linear. We hope to release the “Shparkle” framework for public after a number of successful updates. So please stay tuned!

Download NativeConnect for Free