How to Set Up Notarization on macOS with Electron Builder and Electron Notarize

Written by christarnowski | Published 2021/07/28
Tech Story Tags: electron | programming | macos | electron-builder | notarization | electron-notarize | tutorial | desktop-app

TLDR Apple Notarization Service requires app notarization on macOS to be cryptographically signed with a valid certificate. On Electron Builder (22.7.0) and Electron Notarize (1.0.0), the build process required “a bit” of attention. This guide includes a complete workaround for an issue I’ve experienced that has to do with notarizing on Mac OSX. It turns out the ANS expects the ZIP archive of.app package to be compressed using the PKZIP 2.0 scheme, while the default zip utility, shipped with.macmacOS, features version 3.0 of the generic ZIP algorithm.via the TL;DR App

I ❤️ building things and, when it comes to software, I’ve done that for quite a few platforms and in various programming languages over the years. Recently I’ve been developing a desktop app built with Electron and I must say the whole first-timer experience has been rather pleasing. One thing that required “a bit” of attention was the build process for different platforms (Windows, macOS) and part of it was the app notarization step on macOS. What on paper looked like a really easy thing to do, took me a couple of hours and a lot of detective work to get right 🕵️‍♀️.

Below is a step by step guide on how to set up notarization on macOS when using Electron Builder (22.7.0) and Electron Notarize (1.0.0), including a complete workaround for an issue I’ve experienced that has to do with Apple Notarization Service. Hopefully, I will be able to help you out like a true superhero 🦸🏻‍♂️, so your time and effort can be devoted to other, more pressing matters 🦾.

A bit of context

Want the solution right away 🧐? Skip to the step by step guide.

Why even bother with notarization in the first place? Well, on macOS (and Windows for that matter) there are various security mechanisms built into the operating system to prevent malicious software from being installed and run on a machine. macOS and Windows both require installers and binaries to be cryptographically signed with a valid certificate. On macOS, however, there is an additional build-time notarization step that involves sending a compressed .app archive to Apple’s Notarization Service (ANS) for verification.

In most instances, the whole process is painless, but in my case, i.e. an Electron app with a lot of dependencies and third-party binaries, not so much 🤕. It turns out the ANS expects the ZIP archive of .app package to be compressed using the PKZIP 2.0 scheme, while the default zip utility, shipped with macOS and used by Electron Notarize, features version 3.0 of the generic ZIP algorithm. There are some notable differences between the two and to see what I mean, try manually signing .app, then compressing it using:

  1. Command-line zip utility,

  2. “Compress” option found in Finder,

Then submit it for notarization from the command line. The Finder-created archive will pass, while zip-one will fail.

The zipinfo command line tool reveals that:

  • Finder uses PKZIP 2.0 scheme, while zip version 3.0 of the generic ZIP algorithm.
  • Finder compresses all the files in .app as binaries, while zip treats files according to the content type (code as text, binaries as binaries).
  • Finder includes magical __MACOSX folders to embed macOS-specific attributes into the archive, especially for links to dynamic libraries (e.g. found in some Node modules).

One way of getting around the above issue is to use ditto instead of zip to create a compressed archive of an .app package. Ditto is a command line tool shipped with macOS for copying directories and creating/extracting archives. It uses the same scheme as Finder (PKZIP) and preserves metadata, thus making the output compatible with Apple’s service. The relevant options for executing ditto in this context, i.e. to mimic Finder’s behavior, are:

  • -c and -k to create a PKZIP-compressed archive,
  • —sequesterRsrc to preserve metadata (__MACOSX),
  • —keepParent to embed parent directory name source in the archive.

The complete invocation looks as follows:

ditto -c -k —sequesterRsrc —keepParent APP_NAME.app APP_NAME.app.zip

To apply this to Electron Builder’s notarization flow, you need to monkey patch Electron Notarize’s .app and make the compress step use ditto. This can be done via “afterSign” hook defined in the Electron Builder’s configuration file.

You can learn in a follow up essay why I chose this particular approach. Hope you love it!

Setting up macOS app notarization, including a workaround

Before you start, you first need to properly configure code signing, as per the official documentation of Electron Builder and various guides¹. For completeness sake I’ve included here all the steps required for making the notarization work based on my experience and an excellent work by other developers¹.

  • Create an app-specific password to use with Apple notarization service. Preferably using your organization’s developer Apple ID.
  • Create an Entitlements .plist file specific to your Electron apps. In our case, the following did the trick (entitlements.mac.plist):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <!-- https://github.com/electron/electron-notarize#prerequisites -->
    <key>com.apple.security.cs.allow-jit</key>
    <true/>
    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
    <true/>
    <key>com.apple.security.cs.allow-dyld-environment-variables</key>
    <true/>
    <!-- https://github.com/electron-userland/electron-builder/issues/3940 -->
    <key>com.apple.security.cs.disable-library-validation</key>
    <true/>
  </dict>
</plist>
  • Set entitlements and entitlementInherit options for macOS build in Electron Builder’s configuration file to the .plist created in the previous step.
  • Create a notarize.js script to execute after Electron Builder signs the .app and its contents. Place the file in the build directory defined in Electron Builder’s configuration file (notarize.js):
const {notarize} = require("electron-notarize");

exports.default = async function notarizing(context) {
  const {electronPlatformName, appOutDir} = context;

  if (electronPlatformName !== "darwin") {
    return;
  }

  const appName = context.packager.appInfo.productFilename;

  return await notarize({
    appBundleId: process.env.APP_BUNDLE_ID,
    appPath: `${appOutDir}/${appName}.app`,
    appleId: process.env.APPLE_ID,
    appleIdPassword: process.env.APPLE_ID_PASSWORD,
  });
};
  • Add "afterSign": "./PATH_TO_NOTARIZE_JS_IN_BUILD_DIRECTORY” to Electron Builder’s configuration file.
  • Monkey patch Electron Notarize. The script should run before Electron Builder’s CLI command. In our case, since we’ve taken a very modular approach to general app architecture, the build scripts (TypeScript files) include a separate commons module, which is imported by Electron Notarize patcher. The .ts files can be executed using ts-node via

ts-node -O {"module":"CommonJS"} scripts/patch-electron-notarize.ts 

The patcher itself does one thing only, that is, it replaces the following piece of the code in build/node_modules/electron-notarize/lib/index.js:

spawn('zip', ['-r', '-y', zipPath, path.basename(opts.appPath)]

with

spawn('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', path.basename(opts.appPath), zipPath]

Our code for the commons (patcher-commons.ts):

import {promises as fsp} from "fs";

export type FileContentsTransformer = (content: string) => string;

export async function replaceFileContents(path: string, transformer: FileContentsTransformer) {
  let fh: fsp.FileHandle | null = null;
  let content: string = "";

  try {
    fh = await fsp.open(path, "r");

    if (fh) {
      content = (await fh.readFile()).toString();
    }
  } finally {
    if (fh) {
      await fh.close();
    }
  }

  try {
    fh = await fsp.open(path, "w");

    if (fh) {
      await fh.writeFile(transformer(content));
    }
  } finally {
    if (fh) {
      await fh.close();
    }
  }
}

and the patcher (patch-electron-notarize.ts):

import {FileContentsTransformer, replaceFileContents} from "./common";

const ELECTRON_NOTARIZE_INDEX_PATH = "build/node_modules/electron-notarize/lib/index.js";

async function main() {
  const transformer: FileContentsTransformer = (content: string) => {
    return content.replace(
        "spawn('zip', ['-r', '-y', zipPath, path.basename(opts.appPath)]",
        "spawn('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', path.basename(opts.appPath), zipPath]"
    );
  };

  await replaceFileContents(ELECTRON_NOTARIZE_INDEX_PATH, transformer);
}

// noinspection JSIgnoredPromiseFromCall
main();
  • Set APPLE_ID and APPLE_ID_PASSWORD environment variables (the ones defined in Step 1) before running Electron Builder on your developer machine or in your CI environment. You can use Keychain on your local machine instead.

And that’s pretty much it. You can check out a simple, working example to see how to put this all together. Now you can spend the extra time on something you enjoy doing 🏖!

Three takeaways

  1. When stuck, look for the root cause in the least expected places. In the case of my project, the compression step was the unexpected culprit.
  2. Be stubborn when a particular feature or bugfix is essential to a product’s success. Here, the notarization was important and it took some time to get it right, but the end result is customers feeling safe when installing the software.
  3. Sometimes “working” is good enough. I could develop a better solution, but that would take some precious time. I opted to focus on more pressing issues instead.

Feedback and questions are more than welcome, either in comments or on social media 🙂

Thanks a ton to Piotr Tomiak (@PiotrTomiak) and Jakub Tomanik (@jakub_tomanik) for reading drafts of this article.

¹ Relevant sources:

https://medium.com/@TwitterArchiveEraser/notarize-electron-apps-7a5f988406db

² GitHub Gists of the complete code


Written by christarnowski | Nanananana 🦇
Published by HackerNoon on 2021/07/28