macOS Catalina is coming soon, and with it the requirement for Notarization for apps shipped outside the App Store. Notarization is an extension to Apples GateKeeper; it was introduced optionally for Mojave last year, and becomes mandatory now.

With GateKeeper, apps had to be signed with a Developer ID certificate you obtained from Apple. Getting the certificate was a one-time online step, and once you had your cert, you could sign your apps locally and be done with them. Notarization makes things more complicated.

For one there are a few more restrictions on Notarized apps, although luckily the strictest of them, the need to run under the new "Hardened Runtime", has been lifted at the last minute (we got lucky there, because some key parts of Fire outside of our control just don't work when running under the Hardened Runtime; that said, I would still recommend to try making your apps work under it, if you can. More on that below.).

More importantly, notarized apps need to be, well, notarized. Which is an asynchronous step that involves uploading the compiled binary to Apple, waiting an arbitrary amount of time for their servers to do their thing, and then getting a response back and attaching it to the binary before shipping it to your users.

For Elements, we ship weekly builds created by an automated build script, so this put a dent into our process. In this post, I wanted to give you a peek at what I did to make it halfway automated nonetheless.

Built with Train

Fire, our distributable for Elements on the Mac, is build using a script that runs in Train, our free and open source build JavaScript-based automation tool. If you haven't checked out Train, you should, it's really cool.

The Fire.train script performs all the steps to gather the parts, built the .app, create a .dmg and sign it. It used to be that's all that was needed, and once the script was done, Fire.dmg was ready to be uploaded to the server for you to download.

For Notarization, I had to add some extra steps that I wanna share with you.

First, in my main build script, I added a call to altool that uploads the finished .dmg for notarization. It grabs the password from the KeyChain to authenticate, so I don't have to store it in the semi-public build script.

If the upload succeeds, the script parses out the RequestUUID and saves it to a text file. At this time the build script is done, and all we can do is wait.

notarizeFile = "./Bin/notarizeRequestUUID.txt";
file.remove(notarizeFile);
var notarize = shell.exec("/usr/bin/xcrun", expand('altool --notarize-app --primary-bundle-id com.remobjects.Fire --username deployment@remobjects.com --password @keychain:AC_PASSWORD --file "$(dmgName)"'), { capture: true }).trim();
if (notarize.indexOf("No errors uploading") > -1)
{
	var start = notarize.indexOf("RequestUUID = ");
	if (start > -1)
	{
		notarize = notarize.substring(start+13).trim();
		log("notarize: '$(notarize)'");
		file.write(notarizeFile, notarize)
	}
}

After a while, I'll invoke a new script I created, Notarize.train. This script can be called repeatedly, and will fail gracefully if notarization is still in progress:

notarizeFile = "./Bin/notarizeRequestUUID.txt";
notarizeID = file.read(notarizeFile).trim()
	
var notarize = shell.exec("/usr/bin/xcrun", expand('altool --notarization-info $(notarizeID) -u "mh@dwarfland.com" -p "@keychain:AC_PASSWORD"'), { capture: true }).trim();
if (notarize.indexOf("in progress") > -1)
{
	log("Notarization Still in progress")
}
else
{
	elementsBin = "../Elements/Bin";
	inif = ini.fromFile("$(elementsBin)/VersionInfo.ini");
	compilerVersion = inif.getValue("Options", "VERSION");
	log(compilerVersion);

	var start = notarize.indexOf("Status");
	if (start > -1)
	{
		var status = notarize.substring(start+8).trim();
		var end = status.indexOf("\n")
		if (end > -1)
			status = status.substring(0, end)
		log("Status: "+status)
	}
	
	if (status == "success")
	{
		var staplerResult = shell.exec("/usr/bin/xcrun", expand('stapler staple "./Bin/RemObjects Fire - $(compilerVersion).dmg"'), { capture: true }).trim();
		if (staplerResult.indexOf("worked"))
		{
			log("Stapled successfully!");
		}
		else
		{
			log(staplerResult);
		}
	}
	
	var start = notarize.indexOf("LogFileURL");
	if (start > -1)
	{
		var logFile = notarize.substring(start+12).trim();
		var end = logFile.indexOf("\n")
		if (end > -1)
			logFile = logFile.substring(0, end)
		log("Log: "+logFile)
		if (status != "success")
			shell.exec("/usr/bin/open", logFile)
	}
}
//log(notarize)

First, it reads back the RequestUUID stored earlier, and then it calls altool again to request the status. If the status is "in progress", then all we can do is stop and try again later. This might take minutes, or it might take hours, unfortunately.

If status was "success", all went well, and we can "stable" the notarization to the binary. Luckily, that part is made easy: simply calling stapler with the binary path will figure out all the rest internally.

If the status was an error, we're SOL. The script will grab the log file URL from the result and open it in Safari, leaving it to me to figure out why this Friday's build will be late... ;).

All in all, not rocket science — just another little step of manual labor that makes it trickier to get a new build out. An option for truly automated builds would be to just keep trying the Notarize.train portion automatically, say every five minutes, until it succeeds.

Gotta wait a bit longer...

The Hardened Runtime

Building your apps for the Hardened Runtime is (luckily for Fire) an optional, but still recommended, step for Notarization.

If you're using Elements, enabling this is as easy as setting the "Hardened Runtime:" flag in Project Settings to YES — the build chain will take care of the rest. Well, of the rest, except for actually testing and making sure your app works within the limitations —as there's many things your app is not allowed to do under the HR (or needs specific entries in your .entitlements file to do).

For example, here's the entitlements we had to add to Fire to get it to (mostly) work (unfortunately these weren't enough, and Cocoa debugging would be a complete no go if we built Fire for the HR):

<?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>
        <key>com.apple.security.app-sandbox</key>
        <false/>
        <key>com.apple.security.automation.apple-events</key>
        <true/>
        <key>com.apple.security.cs.allow-dyld-environment-variables</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.cs.debugger</key>
        <true/>
        <key>com.apple.security.files.all</key>
        <true/>
    </dict>
</plist>

Fire needs to

  • Not run Sandboxed, so it can share its configuration and other stuff with the external command line tools.
  • Use Apple Events to open things in Finder or Terminal.
  • Let the compiler load .dylibs from an internal location.
  • Run JITted Mono code internally.
  • Duh, be a debugger.
  • Access the full disk in order to, say, work with the iOS SDKs in Xcode, the Android SDK, or Mono.

In the end it wasn't enough, because com.apple.security.cs.debugger does not allow us to run debugserver, which is crucial for Cocoa debugging. We'll make sure to take that up with Apple in time for macOS 10.16...

Shipping without Notarization

The annoying part about shipping without notarization (but still signing with GateKeeper) is that Apple decided to make no visual distinction between unsigned and signed-but-not-notarized apps, in macOS 10.15 and later.

So when your user downloads an app that's not notarized, they can still bypass the check and run it — but when they do they are basically throwing all caution into the wind, because the OS no longer even shows them if the app is signed (i.e. as trustworthy as jkit was under 10.14) at all, or not :(.

I would have much preferred if Apple had kept a distinction, here and — by all means — still refused to run non-notarized apps by default, but provide information about whether the app is signed or unsigned, when consciously bypassing the check.

Hope This Helps

This post has turned into a bit of a grab bag of (hopefully useful) Notarization-related tips. I hope you found some of it helpful for your own app deployment.