Signing and notarizing a Python MacOS UI application

A guide to making a MacOS application written in Python notarized for Catalina.

Why write a MacOS application in Python?

Python has an amazingly rich ecosystem of libraries, tools and frameworks. It is a clean, modern language, it allows for rapid prototyping and quick development cycles. UI was not the central, focal point of my app, so it made a lot of sense for me to do it in Python: I thought I’d write the core functionality first, and add the UI afterwards.

Packaging the app

MacOS Catalina (released in 2019) still does not have Python 3 installed by default, only Python 2. Therefore, I needed a way to package the whole application into an app bundle and not make it dependent on user’s Python installation. There are several tools that help with that: py2app, briefcase, pyinstaller. I decided to use PyInstaller, it’s mature, flexible, and offers more customization than the other options.

Step 1: PyInstaller spec file

PyInstaller can be driven by command-line options alone, but that works well for the simplest cases only, and packaging any non-trivial app is not one of these. I suggest running it with command-line parameters, which creates the spec file with the default values, and then modifying the spec file to suit your needs better. For example, to set the bundle version to the same value as the application version, and to add some custom plist values:

from my_application import __version__ as package_version
...

...
# The final bundling step in the spec file:
# reference: https://pyinstaller.readthedocs.io/en/stable/spec-files.html#spec-file-options-for-a-mac-os-x-bundle
app = BUNDLE(coll,
    name='My Application.app',
    icon='my_application/resources/my_application.icns',
    bundle_identifier='com.example.my_application',
    info_plist={
      'CFBundleName': 'My Application',
      'CFBundleDisplayName': 'My Application',
      'CFBundleVersion': package_version,
      'CFBundleShortVersionString': package_version,
      'NSRequiresAquaSystemAppearance': 'No',
      'NSHighResolutionCapable': 'True',
    },
)

Step 2: Build the App

This is as simple as

  pyinstaller --noconfirm my_application.spec

My Application.app will be created, which you can (and should) test to make sure it actually works. Some Python packages require tweaks in PyInstaller spec file: including extra data files in the bundle, etc.

Step 3: Sign the App

In order to be able to notarize it, you must use the hardened run-time. The Hardened Runtime doesn’t affect the operation of most apps, but it does disallow certain capabilities. For Python applications specifically, we need to allow unsigned executable memory. If your app relies on any other capability that the Hardened Runtime restricts, add an entitlement to disable that individual protection as well.

Add entitlements.plist to your project’s root:

<?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">
  <!--
    These are required for binaries built by PyInstaller.
    For more info, see:
      https://developer.apple.com/documentation/security/hardened_runtime
      https://github.com/pyinstaller/pyinstaller/issues/4629
  -->
  <dict>
    <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
    <true/>
  </dict>
</plist>

And now we’re ready to sign:

codesign -s Developer -v --deep --timestamp --entitlements entitlements.plist -o runtime "dist/My Application.app"

Note that in order to be able to notarize the app, you need to sign it with your Apple Developer ID certificate. Adjust the “Developer” above to match your certificate name, if needed.

Apple recommends to not “deep-sign”, but in this case it’s actually required as all the bundled Python libraries do need to be signed, not just your main binary.

By adding the --timestamp parameter we include a secure timestamp with the code-signing signature. This is a requirement for notarization.

By adding the entitlements file and passing a -o runtime parameter we enable the hardened runtime, which is also a requirement for notarization.

Step 3: Notarize the App

In order to be able to notarize the app, you need to satisfy some additional requirements:

  • All the binaries in the application must be linked against macOS 10.9 or later SDK. If you just re-built everything with a recent Xcode, this requirement would be satisfied. If, however, you’re packaging some Python dependency with a pre-built binary extension, it might be built against an older SDK. In this case, you’ll need to build this specific package from source.

  • Do not include entitlements that are specifically prohibited. At the time of this writing it’s just one com.apple.security.get-task-allow entitlement.

Store Apple credentials in the keychain

In order to notarize the app, altool must be able to access Apple APIs on your behalf. To secure this access, store your Apple account credentials in the keychain:

xcrun altool --store-password-in-keychain-item "AC_PASSWORD" -u "your-username" -p "your-password"

Notarize it!

Since altool expects a Zip archive and not a bare .app directory, create a Zip file first and then notarize it: .

ditto -c -k --keepParent "dist/My Application.app" dist/MyApplication.zip
xcrun altool --notarize-app -t osx -f dist/MyApplication.zip \
    --primary-bundle-id your.bundle.id -u your-username --password "@keychain:AC_PASSWORD"

Now you need to wait for the notarization results. You’ll get an email from Apple once it’s complete, stating either a success or failure (and linking to the error logs in this case).

Step 4: Staple the App

In the notarization step above, Apple has created a “ticket”, which is basically a database record which matches your app’s signature and saying that it’s been notarized. Your binary has not been modified in any way. When MacOS runs this app, it will contact Apple servers and ask for a ticket. If such a ticket exists, the app is deemed “notarized” This will happen only once, and then MacOS will cache the results.

If we want to speed up this initial application execution, or if we want to be able to run it when offline, we need to “staple this ticket to the app”, which downloads the ticket and attaches it to your binary. This is as simple as:

xcrun stapler staple "dist/MyApplication.app"

This step is optional, but it must be run only after you received an email from Apple stating that the notarization was successful.

Step 5: Verify

Now is the good time to verify that everything is in order:

  spctl --assess --type execute -vvv "dist/My Application.app"

This command uses Gatekeeper directly to assess whether the application is correctly signed and notarized. It should report:

dist/My Application.app: accepted
source=Notarized Developer ID
origin=Developer ID Application: YourName (XXX)

Conclusion

While the process outlined above works today, it is certainly cumbersome and has some downsides:

  • The development and build process is much more complicated than the normal MacOS development process with Xcode. Getting new developers onboard would be tricky.

  • It is hard to control the libraries that are being pulled in into your app. If some of the dependencies were built with Homebrew, the application probably won’t work on MacOS versions older than the build machine.

In my opinion, writing MacOS applications in Python is an acceptable route for prototyping, or for building simple in-house tools quickly, and even notarizing the app is perfectly doable.