How to use Electron auto updater ⚛️

May 15, 2024

I’m writing an Electron app for the first time, and I was wondering how to make it auto update. Turns out it’s relatively easy, but I found a ton of conflicting documentations about it and was quite confused for a while, which is why I’m writing this post.

In this post, I’m gonna focus on a macOS app. I’m not sure how much of this applies to Windows. I’ll update this post when we eventually port the app to Windows!

How does the auto updater works?

On macOS, Electron auto updater uses the Squirrel.Mac framework, a “Cocoa framework for updating macOS apps”.

So ultimately, when it comes to the distribution of auto updates, your source of truth is gonna be Squirrel.

Electron has a built-in autoUpdater API that lets you setFeedURL, checkForUpdates, and quitAndInstall.

On boot, you configure the auto updater with a mysterious, undocumented “feed URL”, and then you check for updates periodically, and when an update is found, you can prompt the user to install the update.

Note: for auto updates to work, your releases must be signed.

What about update-electron-app?

If you read the Electron reference on updating applications, they mention a update-electron-app package, that identifies as “a drop-in module that adds auto updating capabilities to Electron apps”.

This module implements the logic described in the previous section for you, so you just have to call one function on boot and let it deal with periodic checking, and prompting the user to install the update. Cool.

However it’s only meant to work with Electron’s public update service, or static file storage that we’ll talk about later

The typical usage looks like this when using Electron’s public update service:

const { updateElectronApp, UpdateSourceType } = require('update-electron-app')

updateElectronApp({
  updateSource: {
    type: UpdateSourceType.ElectronPublicUpdateService,
    repo: 'github-user/repo'
  }
})

Using update.electronjs.org?

That public update service is hosted by Electron and serves the obscure “feed URL” that we encountered earlier.

In order to use it, you need to point it to a public GitHub repository where you publish releases of your app.

Their service can then respond to auto update requests by checking if there’s a newer release. The app binary is downloaded directly from GitHub releases.

You can also host your own update server. There’s actually a few options you can chose from, and they all comply to this undocumented feed format we still know nothing about.

When using the autoUpdater module, you can configure it like this:

const { autoUpdater } = require('electron')

autoUpdater.setFeedURL({
  url: 'https://server/path/to/feed'
})

That URL seems arbitrary and typically contains the process.platform, maybe process.arch, and your program’s version.

As we saw before, a custom dynamic server won’t work with update-electron-app so you’ll have to implement the logic yourself. Luckily, it’s not very complicated.

What’s behind this feed URL and format?

This format is actually defined by the Squirrel framework.

In case of a dynamic server like in the previous section, the request is as an arbitrary GET request to the URL you configured. It’s important for that URL to include the current app version because your server is expected to respond based on whether or not a new version is available for the given version.

In case no update is available, you should return a 204 No Content.

If an update is available, you should return a 200 OK with the following JSON response:

{
  "url": "https://server/path/to/release.zip",
  "name": "Optional Release Name",
  "notes": "Optional release notes",
  "pub_date": "2024-05-03T12:34:56Z"
}

Now this makes a bit more sense. You can easily make your own server that implements this protocol. Actually, you can probably get away with adding just another endpoint to your existing app. 😎 No need to depend on a third-party service or to self-host and maintain another app. 😅

Static updates format

What’s a bit lesser known is that you don’t even need a dynamic server at all. You can implement auto updates with static files only. 🪶

There’s hints of that in update-electron-app that has a static storage option, as well as Squirrel’s docs that mention a static JSON format.

With update-electron-app, it would look like this:

const { updateElectronApp, UpdateSourceType } = require('update-electron-app')

updateElectronApp({
  updateSource: {
    type: UpdateSourceType.StaticStorage,
    baseUrl: 'https://server/path/to/feed'
  }
})

Note: when using update-electron-app, on macOS, it will append /RELEASES.json to the baseUrl URL that you give when in StaticStorage mode, meaning in the above example, the final URL would be https://server/path/to/feed/RELEASES.json.

There’s no way to opt out of that, so if you’re gonna use this module, that’s something to know when you create the layout of your static file storage. Luckily the automated way to provision static updates with Electron Forge generates a RELEASES.json file by default so it should work out of the box.

As for the native autoUpdater, you need to pass the little documented serverType: 'json':

const { autoUpdater } = require('electron')

autoUpdater.setFeedURL({
  url: 'https://server/path/to/feed.json',
  serverType: 'json'
})

In both cases, the feed URL typically contains process.platofrm and maybe process.arch again, but that seems to be really up to you.

It is supposed to respond with the following schema:

{
  "currentRelease": "1.2.3",
  "releases": [
    {
      "version": "1.2.1",
      "updateTo": {
        "version": "1.2.1",
        "url": "https://server/path/to/1.2.1.zip",
        "name": "Optional Release Name",
        "notes": "Optional release notes",
        "pub_date": "2024-05-02T12:34:56Z"
      }
    },
    {
      "version": "1.2.3",
      "updateTo": {
        "version": "1.2.3",
        "url": "https://server/path/to/1.2.3.zip",
        "name": "Optional Release Name",
        "notes": "Optional release notes",
        "pub_date": "2024-05-03T12:34:56Z"
      }
    }
  ]
}

From this static response, Squirrel is able to determine whether it needs to update, and where to fetch the update from.

Don’t get confused by the updateTo naming. releases contains all the releases of your software, and updateTo just contains some metadata about that release, with the url being the only really important part.

I haven’t tested this, but my guess is that all you really need is the entry containing the currentRelease, e.g.:

{
  "currentRelease": "1.2.3",
  "releases": [
    {
      "version": "1.2.3",
      "updateTo": {
        "version": "1.2.3",
        "url": "https://server/path/to/1.2.3.zip",
        "name": "Optional Release Name",
        "notes": "Optional release notes",
        "pub_date": "2024-05-03T12:34:56Z"
      }
    }
  ]
}

That should be enough for Squirrel to know there’s an update available. I’m not sure keeping the entire history of older releases adds any value.

Auto generating the static update files

From the above section, you should have everything you need to manually craft that updates feed and push it on your static file server with your ZIP updates.

However, if you use Electron Forge, there’s (again little documented) ways to generate this static structure automatically!

update-electron-app hints at @electron-forge/publisher-s3, but there’s also @electron-forge/publisher-gcs, allowing you to generate and upload that static update structure respectively to AWS S3 or Google Cloud Storage.

They both work the same but the documentation of the S3 plugin is more complete when it comes to auto updating.

You need not only to add the S3 or GCS publisher, but also configure @electron-forge/maker-zip with the undocumented option macUpdateManifestBaseUrl.

During the “make” step, Electron will build the ZIP file for the release, but with that option, it will also fetch your current static “update feed”, update the currentRelease, and add a new release entry to the releases array, then output that updated RELEASES.json file next to your ZIP files.

Then the S3 or GCS publisher will know to put that new update feed in the right place in your bucket.

In forge.config.js, it looks like this:

module.exports = {
  makers: [
    {
      name: '@electron-forge/maker-zip',
      config: arch => ({
        macUpdateManifestBaseUrl: `https://my-bucket.s3.amazonaws.com/custom/folder/darwin/${arch}`
      })
    }
  ],
  publishers: [
    {
      name: '@electron-forge/publisher-s3',
      config: {
        bucket: 'my-bucket',
        folder: 'custom/folder',
        public: true
      }
    }
    // {
    //   name: '@electron-forge/publisher-gcs',
    //   config: {
    //     bucket: 'my-bucket',
    //     folder: 'custom/folder',
    //     public: true
    //   }
    // }
  ]
}

In the case of macUpdateManifestBaseUrl, like for update-electron-app in JSON mode, it will automatically append /RELEASES.json, so in the above example, if arch is arm64, the complete feed URL would be https://my-bucket.s3.amazonaws.com/custom/folder/darwin/arm64/RELEASES.json.

Note: if you’re doing universal builds by running electron-forge package --arch universal, then the arch path component will be universal, so in the above example, you would need to configure @electron-forge/maker-zip like this:

module.exports = {
  makers: [
    {
      name: '@electron-forge/maker-zip',
      config: () => ({
        macUpdateManifestBaseUrl: 'https://my-bucket.s3.amazonaws.com/custom/folder/darwin/universal'
      })
    }
  ]
}

Conclusion

Will you use Electron’s hosted update service? Or self-host an open-source update server? Or instead implement your own dynamic endpoint? Or maybe you’ll just push static updates on S3, GCS, or your own file server?

Regardless what you chose, you should now have all the elements you need to implement auto updates in your Electron app on macOS the way that suits you best! Cheers. ✌️

Want to leave a comment?

Join the discussion on Twitter or send me an email! 💌
This post helped you? Buy me a coffee! 🍻