Nov 05, 2023

Releasing Electron apps for macOS

Staying close to last week’s post on figuring out the state architecture for Electron applications, this week I’m looking into builds for macOS. Even without Electron, building applications for macOS entails a lot of steps to go from your code to shipping a packaged binary to your users, so most of the complexity is not specific to Electron.

In this guide, I will focus on Electron applications distributed outside the Mac App Store (MAS). If you’re curious about distributing your Electron application through MAS, a great point to start is the official Electron guide.

Code Signing and Notarization

Code Signing helps ensure the application a user installs and runs has been created by a specific known developer (you) and has not been tampered with along the way. Gatekeeper is the component running on every Mac to prevent launching applications that are unsigned or missing valid a signature. Notarization is a process to add even more confidence about the origin of a packaged application by uploading the entire package to Apple’s servers, running automated security checks, and stapling a ticket or “seal of approval” to the application. Upon the first launch, Gatekeeper not only checks for the code signatures but also verifies the ticket belongs to a known Apple developer before proceeding to launch the application.

To sign an application, you need to request a Developer ID Application certificate. This specific type of certificate is designed to sign macOS apps before distributing them outside the Mac App Store. Apple Development and Apple Distribution certificates are used for local testing and uploading apps to MAS, and are not required in this guide.

You can create your first Developer ID Application certificate online or in Xcode. In addition to having the certificate installed in your local Keychain, you should also create an app-specific password for your Apple ID to allow the build pipeline to notarize your app.

Electron Forge

Now that we’ve covered the two most important steps in building your macOS application, let’s get started. To abstract some of the complexity, we’ll manage our Electron project with Electron Forge, a simple plugin-based framework to develop and build Electron apps created by the Electron team. With Electron Forge, you get to use the latest Electron features related to packaging and distributing your apps, all while enabling multi-platform builds out of the box.

To go from your code to the .app or .dmg volume your users expect, Electron Forge runs three steps: package, make, and publish. In the package step, all source code is bundled using Webpack or Vite, assets are copied, and code signing and notarization are run when building for macOS. With the resulting executable app bundle, the make step prepares installers or archives you can use to distribute your app more easily than uploading an executable to some static storage. In the final publish step, Electron Forge optionally uploads all artifacts to GitHub or a static storage (e.g. S3 Bucket) location you specify.

Publishers (plugins handling the publish step) are interesting because they hook into the auto-update workflow. Let’s dive into how Electron apps are updated outside of the Mac App Store.

Configuring Code Signing and Notarization

To make sure your app is always signed and notarized, we need to configure the process in your Forge config. Before we get started, we need to find the certificate identifier used for signing. You may see multiple certificates installed in your Keychain, simply pick the Developer ID Application certificate you want to use.

security find-identity -p codesigning -v

Copy the certificate and enter it as the identity used by osx-sign. The Notarization process depends on supplying the app-specific password and developer team ID (the same identifier written in parentheses in the identity field/your code signing certificate).

const config: ForgeConfig = {
  // Runs every time
  packagerConfig: {
		// https://www.electronforge.io/guides/code-signing/code-signing-macos
    osxSign: {
            identity: "Developer ID Application: ... (...)",
    },
    osxNotarize: {
            // https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution
            tool: "notarytool",
            appleId: process.env.APPLE_ID!,
            appleIdPassword: process.env.APPLE_PASSWORD!,
            teamId: process.env.APPLE_TEAM_ID!,
          },
	}
};

Auto-updating Electron apps

While you could instruct your users to download the newest .app file and move it into their Applications directory for every update, you probably want to take care of that. Even better, you just want to push the newest build and don’t worry about updates at all. Your users should just receive a dialog and decide when to update to the latest version on their own.

This use case is just what the Electron autoUpdater module was built for. Using Squirrel under the hood, you can set up an update server that provides applications with nudges to update to a newer version. Apps using the autoUpdater module periodically check for updates, install the newer application binary and properly restart the app once updated, completely transparently to the user. To use this experience, you’ll have to set up three things: 1) You need to upload all artifacts to a public location, 2) you need to host a dynamic or static update server, and 3) your app needs to periodically contact the update server.

Uploading all artifacts to S3

While you could manually update your packaged app to S3, this can be automated by using the S3 publisher provided by Electron Forge, which will upload all artifacts to the specified bucket.

// Runs only for npm run publish
publishers: [
  {
		// supply AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION env vars
    // https://www.electronforge.io/config/publishers/s3
    name: "@electron-forge/publisher-s3",
    config: {
      bucket: "my-public-releases-bucket",
      public: true,
    },
  },
],

Hosting a static releases server

Once you’ve run the initial build and uploaded the first files to your S3 bucket, you can create RELEASES.json files in the platform directory (my-app/arch/platform) and populate them with the initial release. The release file is consumed by Squirrel, the auto-updater framework, to detect newer versions to update to.

{
  "currentRelease": "1.0.0",
  "releases": [
    {
      "version": "1.0.0",
      "updateTo": {
        "name": "my-app v1.0.0",
        "version": "1.0.0",
        "pub_date": "2023-11-02T12:31:25.840Z",
        "url": "https://my-public-releases-bucket.s3.eu-central-1.amazonaws.com/my-app/darwin/x86/my-app-darwin-x86-1.0.0.zip",
        "notes": "bug fixes and performance improvements"
      }
    }
  ]
}

Automatically checking for updates

While you could just use the autoUpdater module by itself, Electron provides another minimal abstraction to make the most popular implementations even easier. [update-electron-app](https://github.com/electron/update-electron-app) supports static file storage backends like S3, instructs autoUpdater to check for updates, downloads new versions automatically, and will prompt users to restart the application once updated.

const { updateElectronApp, UpdateSourceType } = require('update-electron-app')
updateElectronApp({
  updateSource: {
    type: UpdateSourceType.StaticStorage,
    baseUrl: `https://my-public-releases-bucket.s3.eu-central-1.amazonaws.com/my-app/${process.platform}/${process.arch}`
  }
})

(Optional) Automatically publishing releases

After you have published the initial release, you can instruct Electron Forge to automatically publish subsequent releases immediately, without manual intervention. In the makers section of your Forge configuration, you can update the Squirrel and ZIP makers to include the bucket base URL. In addition to producing the installer/volume package, the makers will write an updated RELEASES.json file to the disk, which is then uploaded to S3 by the publisher plugin.

new MakerSquirrel((arch) => ({
  remoteReleases: `https://my-public-releases-bucket.s3.eu-central-1.amazonaws.com/my-app/win32/${arch}`,
})),
new MakerZIP(
  (arch) => ({
		macUpdateManifestBaseUrl: `https://my-public-releases-bucket.s3.eu-central-1.amazonaws.com/my-app/darwin/${arch}`,
  }),
  ["darwin"],
),

I hope this guide helped you get an overview of the packaging and distribution workflow for Electron apps on macOS. Feel free to reach out with feedback, questions, or suggestions!