How we built an app update flow our users love

Thomas Paul Mann
Raycast
Published in
8 min readOct 12, 2020

Prompts for updates pop up on our devices almost every day. In reality, most of them get ignored and users stay on outdated app versions. At Raycast, we ship new features and address feedback with weekly updates. It’s crucial that our users are on the latest version to get the best experience. To make sure that the majority stays up-to-date, we set ourselves the goal to make updating Raycast fun!

This is how updating the app looks:

New app update experience in Raycast

Our custom updating experience helps us to move the majority of our users to a new version within 24 hours. On top, our users praise the experience:

Love the update feature, that’s got to be the fastest and easiest update of any application I have ever used. Keep up the good work.Brandon

I like the first entry when opening when there is a new update 👍 — Max

Damn the update process is so good 👌🏼 The update prompt command that gets surfaced at the top and then release notes. Nice work! — Alex

Let’s dive into why and how we built this crucial piece of infrastructure.

Why bother about app updates?

Raycast is different to other desktop apps: It’s ephemeral and you open it to have quick access to your tools. If you don’t need it, it stays out of your way — the app doesn’t have a dock icon, neither does it show up in your app switcher. It’s designed to keep you focused.

We release a new version every week. With such a short release cycle, our users update the app constantly. Having a window popping up to prompt for an update just when you wanted to quickly check something doesn’t feel right. We wanted our updating experience align with our product philosophies: Simple, fast and delightful.

The old app update flow: In the middle of some action, the app update pops up. Not cool!

Finding time to change fundamental infrastructure is challenging for a small startup. There is constantly something else popping up that seems more urgent. What’s easy to forget, is that infrastructure will pay off in the long run. In the case of our updater, it’s something that most users do once a week.

How did we pull it off?

In August, we improved the foundation and iterated on the design of the app. This was the time when we also decided to build our own app updater. The first step was to define requirements: We wanted to make sure that the update flow integrates into the user experience, it’s easy to see What’s New after an update and support internal releases for our nightly builds, which we use to dogfood new features. In short, it should be fun to update the app!

With the requirements defined, we looked into existing frameworks for app updates:

  • Sparkle is the go-to framework for updating macOS apps. We used to use it but it doesn’t allow too custom UIs. By the time we evaluated it, there was ongoing work on a successor to add support for more customizations. The successor wasn’t production ready and we decided against forking the framework.
  • Squirrel is another framework for updating macOS apps. This one depends on ReactiveCocoa and Mantle. We don’t use neither of these dependencies. Taking on such heavy dependencies for just updating an app didn’t feel right. Takeaway: Squirrel uses a server-side component to rollout releases. We picked this up and also use a simple API to serve our updates.
  • Max Howell’s AppUpdater is a single Swift file that automatically updates open source macOS apps from GitHub Releases. It checks for a new version once a day and silently upgrades the app. Raycast isn’t open source and we want to show Release Notes after updates. AppUpdater doesn’t support this. Takeaway: AppUpdater relies on GitHub Releases which inspired us to store our updates on GitHub as well.
  • Our own Vadim wrote a blog post about how he developed a custom update engine for NativeConnect. The update flow is optimized for his use case which simplifies the code significantly. Takeaway: Vadim’s approach is opinionated which makes the code simple and transparent. We use similar scripts to check for code signatures and replace bundles.

Eventually, we decided against forking any of the mentioned frameworks. We took inspirations from each of them and came up with the following solution:

  1. Use GitHub Releases to store app updates
  2. Have a server-side component to rollout app updates
  3. Download an update, check its code signature and replace the existing bundle
  4. Show release notes after the app is relaunched

Show me the code…

Let’s walk through a new app update step by step.

Publish an update

It starts with triggering the CI to build a new version. For this, we have a Script Command in Raycast that creates a new internal or public release. The script bumps the app version, sets a tag and pushes the changes. Our CI picks up the new tag and kicks off a release build with GitHub Actions.

Script Command to publish new release via Raycast

After a successful build, the CI drafts a new release with the DMG attached. The oncall fills out the prepared release notes and publishes the release. Then it’s available to download.

Release notes stored on GitHub

Check for updates

The app checks for updates at least once a day. For this, it makes a networking request to our own server with the current app version as a query parameter. The server is a simple middleware that abstracts away the GitHub Releases API. This is helpful in the case we want to switch to another storage system for the binaries. The endpoint takes the current app version, fetches the latest release from GitHub and compares the semantic version numbers. If the client is up-to-date, it returns a 204 HTTP status code. If the client needs an update, it responds with 200 and some metadata about the update:

Release API response

We use NSBackgroundActivityScheduler and URLSession for automatic checks. The scheduler isn’t 100% reliable. In our case, we want to make sure that the client checks at least once a day for an update. To guarantee this, we check the last time we requested an app update with Calendar.current.isDateInToday(lastAppUpdateCheckDate).

In addition to the automatic checks, we have a Check For Updates command and a status menu item to get the latest release.

Download, unbundle and security check

To make the installation frictionless, the client starts downloading the DMG in the background without asking the user. We can do this because our app is small (~10 MB at the time of this post) and our users are usually on WiFi. After successful downloading, we mount the disk image with the following script:

Mount downloaded disk image

Next, we move the .app file from the mounted DMG to the Application Support directory and compare the bundle identifiers. If the bundle identifiers are the same, we compare the code signatures. We use Apple’s Security framework for this:

Compare code signature of bundles

What the above code does, is pretty much the same as the CLI tool codesign. We then simply compare the extracted code signature of the new update with the one of the existing app. If everything matches, we have an update to install 🎉

Last step, be a good citizen and detach the disk image to clean up after ourselves:

Detach disk image

Install update

After successful security checks, the available app update is presented on the top of the root search. You just need to hit
to install the update ✨

New update is available to install

First, we use the xattr command line tool to remove the quarantine flag. This avoids a system alert asking the user to run this program because it was downloaded from the internet. We can do this because we know it’s our own verified binary that we execute.

Remove quarantine flag

Next, we simply swap the current app with the update. Thanks to Unix, we can just move the currently running app to the Trash.

Swap bundles

With some special set of instructions, we schedule the update to launch when the current version quits. This neat trick is borrowed from the AppMover codebase. We add a launch argument to identify the new update.

Launch update when existing app terminates

Last but not least, we terminate the old version of the app which is now located in the Trash. The above script makes sure that the update opens and that’s it. We’re on a new version of Raycast 🏁

Show Release Notes

When the update launches, we interpret the previously mentioned launch argument. This way we can detect if it’s a new version and show the release notes. The notes are fetched via our middleware from the GitHub Releases API.

Release notes surfaced after updating the app

Was it worth it?

We had a working app updating flow and nobody asked us to improve it. So was it worth all the effort?! The short answer is yes. The new flow is much smoother and users sent us positive feedback about it. It also allowed us to dogfood our own features quicker. The team can easily install the daily internal releases. Building foundational pieces as a startup with not many resources sounds scary. If you are opinionated about your solution and optimize for your specific use case, complex problems become much simpler and suddenly are easy to implement.

Now we own the code, understand what it does and can extend it. We recently added automatic background updates to make sure that our users are on the latest version of Raycast. This was easy to add and we tailored the solution again to our specific setup. We have more ideas like differentiate hotfixes and minor/major releases, stage the rollout of updates or introduce another update channel for alpha testers. All of this is now much easier when we own the code end to end.

If you want to build something high quality and outstanding, you have to go the extra mile and take the risk to tackle hard problems. In our case it paid off and I’m sure it will do for you as well.

Want to experience the update flow and the app yourself?! Head over to https://raycast.com to get started.

--

--