Notarization is required as of macOS 10.15 (Catalina), and it’s a bit of a minefield - doubly so for a Java application, or anything built outside Xcode.

I’d seen a few good guides around the place explaining how to get this working in non-typical build environments, but Java had its own quirks I needed to deal with. This focuses on Feud, my LibGDX game, but should be applicable to most Java applications.

What is notarization?

If you’re reading this I’m assuming it’s specifically because you want to notarize something but just in case, let’s go over the basics.

Apple has a piece of software on macOS called Gatekeeper. This is designed (in theory) to prevent malicious software from being accidentally run on macOS. That’s why you’ll sometimes see the “This software is from an unidentified source” popup when trying to run something not downloaded from the Mac App Store. Fine.

In 10.15, Gatekeeper introduced the additional requirement that any piece of software you want to run must be notarized - the developer has sent it off to Apple, Apple have looked at it and gone “yes mate this is legit” and handed it back to the developer, who then puts a special “Apple approved” stamp on the app before uploading it to wherever. This is a change from 10.14 where this requirement only existed for Mac App Store apps. There’s a way for users to get around this (detailed in the Gatekeeper link) but it’s tedious, and they’d run into issues when launching an app through a launcher like Steam.

The TL;DR of all of this is that if you want to sensibly distribute macOS software you now need to notarize it. Sorry.

What do I need?

A big pile of money! No, but in all seriousness:

  • An Apple developer license. Yes, the $100/year one. Sorry.
  • A Mac running 10.13.7 (High Sierra) or later.
  • Xcode
  • Your app to be 64-bit. If you aren’t compiling to 64-bit in the year of our lord 2020 I am a bit concerned. If you’re trying to notarize an old app, good news! - it won’t work on Catalina regardless, as 32-bit support has been dropped. Sorry, again.

Once you’ve jumped through these hoops, we can commence jumping through the other seven thousand hoops.

Building and packaging

This step will be a little different for everyone. We want to produce a .app file that bundles a JRE. I’ll describe my process for a LibGDX app, but if you’re using something else or your application isn’t a game at all, here are some broad requirements:

  • Any binaries must link against at least the macOS 10.9 SDK.
  • You must enable the hardened runtime, and ship a JRE that uses the hardened runtime. I use AdoptOpenJDK, which supports it on the newest versions (I use 8u252-b09.1).

To package my LibGDX game as a .app file, I use packr. Unfortunately the mainline packr repo appears to be mostly unmaintained at the time of writing, so I’ve been using karlsabo’s fork. Please go and star that, they’re doing god’s work keeping the tool maintained.

Here’s my incantation. Note that I use LWJGL3, hence -XstartOnFirstThread. YMMV.

java -jar packr.jar \
    --platform mac \
    --jdk jdk_mac.zip \
    --executable feud \
    --classpath feud_desktop.jar \
    --mainclass com.bearwaves.feud.desktop.DesktopLauncher \
    --vmargs Xmx2G XstartOnFirstThread "Dsun.java2d.dpiaware=true" \
    --icon desktop/icons.icns \
    --minimizejre ./buildTools/minimise.json \
    --output build/desktop/mac/Feud.app

You should replace those values with your own where relevant. If you’re on Java 8 like myself, the following minifier JSON should help (other platforms omitted for brevity):

{
  "reduce": [
      ],
  "remove": [
    {
      "platform": "*",
      "paths": [
        "jre/bin/rmid",
        "jre/bin/rmiregistry",
        "jre/bin/tnameserv",
        "jre/bin/keytool",
        "jre/bin/policytool",
        "jre/bin/orbd",
        "jre/bin/servertool",
        "jre/bin/javaws",
        "jre/lib/javaws.jar",
        "jre/lib/jfr.jar",
        "jre/lib/oblique-fonts",
        "jre/lib/desktop",
        "jre/plugin",

        "jre/THIRDPARTYLICENSEREADME-JAVAFX.txt",
        "jre/lib/ant-javafx.jar",
        "jre/lib/javafx.properties",
        "jre/lib/jfxswt.jar"
      ]
    },
    {
      "platform": "macos",
      "paths": [
        "jre/lib/fxplugins.dylib",
        "jre/lib/libdecora_sse.so",
        "jre/lib/libdecora-sse.dylib",
        "jre/lib/libfxplugins.so",
        "jre/lib/libglass.dylib",
        "jre/lib/libglib-lite.dylib",
        "jre/lib/libgstreamer-lite.dylib",
        "jre/lib/libjavafx_font_t2k.dylib",
        "jre/lib/libjavafx-font.dylib",
        "jre/lib/libjavafx-iio.dylib",
        "jre/lib/libjfxmedia.dylib",
        "jre/lib/libjfxwebkit.dylib",
        "jre/lib/libprism_common.dylib",
        "jre/lib/libprism_sw.dylib",
        "jre/lib/libprism-es2.dylib"
      ]
    }
  ]
}

Those on a newer Java like 11 will probably have a small enough JRE that minification ceases to matter.

However you end up doing it, you should finish this process with a .app. We’re not done yet. Not nearly.

Signing

Signing is the process of putting our fingerprint on every little binary in our application so that users can trust it’s actually from us and not some hacked version inserted by a bad actor.

We need to create a special certificate for this. Head over to the developer console and create a Developer ID Application certificate. You’ll have to create a CSR from Keychain Access and upload it - the instructions on that page should see you through.

Got that? Load it into Keychain Access and copy the name - all of it. It’ll be something like

Developer ID Application: YOUR COOL NAME (SHFJ28E7Q)

You’ll also need to create a macOS app identifier for your application. Use an explicit bundle ID and remember it. Mine is com.bearwaves.feud.macos.

We also need to create an entitlements file. This is essentially a list of permissions we want Apple to grant our app. You can have mine for free:

<?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.cs.allow-jit</key>
	<true/>
	<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
	<true/>
	<key>com.apple.security.cs.disable-library-validation</key>
	<true/>
	<key>com.apple.security.cs.allow-dyld-environment-variables</key>
	<true/>
</dict>
</plist>

If you’re copying this please make sure to use tabs as indents rather than spaces, as I believe that’s actually a requirement.

We can start signing now. Jump into your terminal and head over to the directory where your .app lives (if you don’t know how to use a terminal I cannot stress enough how useful it is).

We’re going to first clear all the signing information from the .app, just in case you started following this guide halfway through.

xattr -cr Feud.app

Fresh and clean. Did you know that a .app is actually a directory? You’re about to. In we go!

cd Feud.app

What we’re going to do now is sign every single macOS binary inside the app bundle. These will be located in a few places:

  • Your actual app binary - probably in Contents/MacOS.
  • Any additional libraries or frameworks you’re shipping. I ship my own versions of libpng and libfreetype - these live in Contents/Frameworks.
  • The binaries inside the JAR. Packr places mine in Contents/Resources/; your packaging tool may be different.

You might be wondering about the JRE itself. I’ve found that as this is already codesigned by AdoptOpenJDK I don’t need to do that here.

I do all this stuff inside a shell script, and as such I’ve made a little sign function you can use. If you’re not in a script you can just run the command against each binary.

function sign() {
  # The DIST_CERT variable is the certificate name from earlier.
  codesign --force --options runtime --timestamp --sign "$DIST_CERT" \
    --entitlements "path/to/my/entitlement.plist" "$1"
}

You might have seen in documentation somewhere that the codesign tool has a --deep flag. I tried using this in all its various permutations and was never able to get it to work, and the advice seems to be not to use it. I’m not really sure what it’s for.

So, we need to sign every binary, including the ones inside the JAR. To do this I just unzip the JAR, sign all the binaries I know about, and then zip it back up. The actual binaries that need signed will vary from application to application depending on what libraries you’ve included; basically it’s anything with a .dylib extension. The script I have looks like this:

pushd Feud.app/Contents/Resources
mkdir jar
# platform is either 'desktop' or 'steam'
mv "feud_${platform}.jar" jar/
pushd jar/
unzip "feud_${platform}.jar"
rm "feud_${platform}.jar"

dylibs=(
  "libopenal" "libgdx64" "libgdx-freetype64" "libglfw"
  "libjemalloc" "liblwjgl" "liblwjgl_opengl"
)

if [ "${platform}" == "steam" ]; then
  dylibs=(
    "${dylibs[@]}" "libsteam_api" "libsteamworks4j-encryptedappticket"
    "libsteamworks4j-server" "libsteamworks4j"
  )
fi

for lib in "${dylibs[@]}"; do
  # sign is the function a little up the page
  sign "${lib}.dylib"
done

zip -r "../feud_${platform}.jar" *
popd

rm -rf jar/
popd

Once everything inside the .app is signed, we should be able to validate it locally.

codesign --verify --verbose=4 'Feud.app'

For my app, I get this output:

--prepared:/Users/joel/Feud.app/Contents/Frameworks/libpng16.16.dylib
--validated:/Users/joel/Feud.app/Contents/Frameworks/libpng16.16.dylib
--prepared:/Users/joel/Feud.app/Contents/Frameworks/libfreetype.6.dylib
--validated:/Users/joel/Feud.app/Contents/Frameworks/libfreetype.6.dylib
/Users/joel/Feud.app: valid on disk
/Users/joel/Feud.app: satisfies its Designated Requirement

Hopefully your last line looks something similar. Unfortunately this doesn’t tell us it’s definitely correct, as codesign won’t look inside the JAR. This is the point where we have to send it up to Tim Apple and hope he’s feeling merciful.

Notarizing

This is it, the moment you’ve been waiting for, when your app gets to strut its stuff in front of the judges and you find out just how bad its singing is.

To do this we’re going to need what’s called an app-specific password. This is a single-use key that you can generate via your Apple account.

Next, we need to zip up our .app bundle (cos it’s a directory, remember?) and start the upload. I do something like this:

zip -r Feud.app.zip Feud.app
xcrun altool -t osx -f Feud.app.zip \
  --primary-bundle-id com.bearwaves.feud.macos --notarize-app \
  --username me@example.com \
  # APP_PASS is the single-use password you created
  --password "$APP_PASS"

Apple aren’t going to give you the results right away; that would be too easy. Instead, they’ll eventually give you a UUID which you can use to check the status of your notarization request, like this:

xcrun altool --notarization-info "$UUID" \
  --username me@example.com \
  --password "$APP_PASS"

You’ll have to wait a few minutes. You can rerun this command as many times as you like to check the status. Eventually you’ll either get a success message, or you’ll get a link to a bunch of logs explaining what went wrong. They’ll also email you. Yes, every time.

The failure logs are organised per-file and will tell you what’s wrong for each. The most likely ones you’ll get are:

  • the binary isn’t signed, you must have missed it
  • the binary is linked against a too-old version of the macOS SDK
  • the binary doesn’t use the hardened runtime - you have a bad JRE, or forgot the --options runtime flag
  • the binary doesn’t have a timestamp - you missed the --timestamp flag

It’s usually enough to fix the offending binary, re-zip and re-upload.

Eventually you should get the all-clear, at which point we’re home and dry. The hardest part is over.

Stapling

Stapling is basically taking the little receipt of goodness that Apple graciously bestowed upon us and attaching it to the app bundle. This just needs one command:

xcrun stapler staple Feud.app

You can check this worked like this:

spctl --assess --verbose Feud.app

If you see something like this, congratulations; you’re done.

/Users/joel/Feud.app: accepted
source=Notarized Developer

The End

This was an exhausting process to go through. I trawled through forum posts and had many late nights trying to get the damn thing to work on Java. I hope this post means you don’t.

It’s been nearly a year since Catalina launched. Here’s hoping Apple don’t make any more massive changes to developer requirements on macOS any time soon!

What? Oh for fuck’s sake.