Tuesday, January 21, 2025

How to make macOS installers for JUCE with pkgbuild and productbuild

Sudara

Sudara is building Sine Machine, an additive synth in JUCE, writing about technical challenges along the way.

https://melatonin.dev

Making installers is a big part of the "last mile" before putting your audio plugin up for sale.

There are other options on macOS, but in my opinion the most straightforward path is to use Apple's first-party tooling, pkgbuild and productbuild to build a "flat" .pkg installer that installs all your plugins, like this:

Be aware the whole pipeline is a bit... "old school." The tooling hasn't changed significantly in years. The Mac App Store has become Apple's preferred distribution route over 3rd party installers. But luckily it's still all fairly straightforward and stable.

High Level Steps

This guide assumes you want to distribute a .pkg installer that will install multiple components.

A component is just a plugin type you are distributing: a standalone .app, an AU .component, a .vst3 or a .clap.

Regardless of whether you are setting this up for Continuous Integration or a local script, you'll follow this order of events:

  • Sign your plugin executables. Code sign each "component." We won't go into detail here, but you can read my other articles on codesigning windows with Azure and codesigning macOS in CI.
  • Run pkgbuild for each component. This creates a .pkg file for each component.
  • Create your distribution.xml. This tells the installer what will be installed and lets you customize it.
  • Run productbuild. This makes the final .pkg installer.
  • Notarize and staple. This uploads the installer to Apple to check it is happy and free of malware. Read more.

What you'll need

You'll need the following to proceed:

  • Plugin binaries that are built and code signed. See codesigning macOS in CI.
  • Your BUNDLE_ID. If you are using JUCE, this is easy, it's the same string you are already setting as BUNDLE_ID in juce_add_plugin in CMake or as the "Bundle Identifier" in Projucer. Here's we'll use com.mycompany.myproduct as an example.
  • A version number.
  • For notarization, you'll need your apple id, your TEAM_ID, and an "application password" from Apple. Note this is not your apple id password (because that would require 2FA), read more here.
  • If you are shipping a Standalone, it needs to specify an entitlement to have permission to be written to /Applications. You do this at code sign time, like so:
codesign --force -s "${{ secrets.DEVELOPER_ID_APPLICATION}}" -v --add-entitlement "com.apple.security.files.user-selected.read-write" true "${{ env.STANDALONE_PATH }}" --deep --strict --options=runtime --timestamp

Step 1: pkgbuild each plugin

First, we'll run each component through pkgbuild to create a .pkg. That means we'll have one .pkg per plugin type. This is where you specify where the component should install on the end user's machine.

For an AU plugin called MyPlugin.component, the command might looks like this:

pkgbuild --identifier "${{ env.BUNDLE_ID }}.au.pkg" --version $VERSION --component "${{ env.AU_PATH }}" --install-location "/Library/Audio/Plug-Ins/Components" "packaging/${{ env.PRODUCT_NAME }}.au.pkg"

Here's some hand-holding with the arguments:

  • --identifier is your bundle id (like com.mycompany.myproduct) plus a suffix unique to the component, au.pkg.
  • --component is the path to a single component (plugin type), in this case, the AU.
  • --install-location is for the end user's machine and best copied from this example. These folders are standardized on the system, living in /Library/Audio/Plug-Ins/<format>.
  • The last argument is the path to the intermediate .pkg we want built. This will later be consumed by the distribution.xml when putting things together.

Optional Standalone details

There another subtle issue I find worth resolving with the standalone.

If the edge case that the application already exists on the user's system, the installer will replace it wherever it happens to be found on the filesystem. However, I personally always want installation to occur into /Applications, even if the user had an older copy elsewhere, such as on their Desktop.

I found resolving this makes things more consistent, especially for beta testers.

We can do a bit of a dance, editing the standalone .pkg built in the last step with plutil, the property list utility, to set BundleIsRelocatable to NO.

pkgbuild --analyze --root "$(dirname "${{ env.STANDALONE_PATH }}")" standalone.plist
plutil -replace BundleIsRelocatable -bool NO standalone.plist
pkgbuild --identifier "${{ env.BUNDLE_ID }}.app.pkg" --version $VERSION --root "$(dirname "${{ env.STANDALONE_PATH }}")" --component-plist standalone.plist --install-location "/Applications" --sign "${{ secrets.DEVELOPER_ID_INSTALLER }}" --timestamp "packaging/${{ env.PRODUCT_NAME }}.app.pkg"

Step 2: Create a distribution.xml

The distribution.xml is what productbuild ingests to produce the final installer.

There are many ways to approach this. Some people script it with python. Others use productbuild --synthesize, supplying it with the sub-packages. Others have very long and hard to follow shell scripts.

Although synthesize is an awesome argument name for audio devs, I prefer to be boring and explicit and just manually create the xml. It's only 35 lines for 4 plugin formats. Not bad. If you like, you can first synthesize, then manually tweak. IMO it's not worth scripting.

If you want to dig deep, check out the official schema documentation for the distribution.xml. Basically, all we are doing is providing some metadata, and linking the intermediate .pkg files you made along with some other options.

Specify metadata

There are few single xml elements you can customize:

<os-version min="10.13" />
<license file="EULA" />
<readme file="README" />

Provide a choice-outline

This is a list of the components you want to be installed. Put them inside a choices-outline element:

<choices-outline>
    <line choice="app" />
    <line choice="vst3" />
    <line choice="au" />
    <line choice="clap" />
</choices-outline>

Provide choice detail and link to the intermediate .pkg

You can specify a title and whether or not it's selected by default. Then link the choice to the intermediate .pkg components via a pkg-ref sub-element:

<choice id="vst3" visible="true" start_selected="true" title="${PRODUCT_NAME} VST3">
    <pkg-ref id="${BUNDLE_ID}.vst3.pkg" version="${VERSION}" onConclusion="none">${PRODUCT_NAME}.vst3.pkg</pkg-ref>
</choice>

You can see a full example of a distribution.xml in my Pamplejuce JUCE template project.

Step 3: Run productbuild

Almost done! This step is is nice and easy. Just a one-liner that consumes the distribution.xml and produces the final package.

It takes a DEVELOPER_ID_INSTALLER identifier for signing the final package:

productbuild --resources ./resources --distribution distribution.xml --sign "${{ secrets.DEVELOPER_ID_INSTALLER }}" --timestamp "${{ env.ARTIFACT_NAME }}.pkg"

Step 4: Notarize and Staple

A lot of fuss can be made about Notarization being complicated, but it's just 2 lines.

Notarization is for "containers" like .zip or .pkg or .dmg.

So we will notarize the .pkg directly, uploading it to Apple's server to check for malware, etc.

xcrun notarytool submit "${{ env.ARTIFACT_NAME }}.pkg" --apple-id ${{ secrets.NOTARIZATION_USERNAME }} --password ${{ secrets.NOTARIZATION_PASSWORD }} --team-id ${{ secrets.TEAM_ID }} --wait
xcrun stapler staple "${{ env.ARTIFACT_NAME }}.pkg"

Customizing the installer

You can customize to specify things like a background image, a readme and license, like so:

Over the top themed installer

Go nuts.

Note that to support macOS dark theme, you must provide both background and background-darkAqua elements in the xml, even if they reference the same image:

<background file="background.png" mime-type="image/png" scaling="tofit" alignment="bottomleft"/>
<background-darkAqua file="background.png" mime-type="image/png" scaling="tofit" alignment="bottomleft"/>

That's it!

Not too bad, right?

Be sure to check out the Pamplejuce distribution.xml to compare notes.

If you are using GitHub, also check out the pkgbuild, Productbuild and Notarize step of the GitHub workflow — they contain all the commands listed here, presented together in one step.

Troubleshooting

There are 2 places you'll probably find out you did something wrong:

  • Running the final .pkg
  • Notarization

The former is usually pretty easy to diagnose. Here's some tips on problems exposed by notarization.

Check the logs

Check the details of a notarization run with xcrun notarytool log using its UUID, like so:

xcrun notarytool log 17e111ef-4c68-4491-ac27-8e57bc875 --apple-id "you@youremail.com" --password "mypassword" --team-id 2LEGIT2QUIT

You'll get a bunch of json back, including any errors. For example, I once got this:

"Package YourPlugin.pkg has no signed executables or bundles. No tickets can be generated."

I then used codesign --verify -v /path/to/my/plugin.component to check the code signing details on my executables.

Binary is not signed with a valid Developer ID certificate

"path": "YourPlugin.pkg",
      "message": "The binary is not signed with a valid Developer ID certificate.",
      "docUrl": "https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution/resolving_common_notarization_issues#3087721",

Inspect your final "flat" package with pkgutil --check-signature YourPlugin.pkg

Make sure you are using the Developer ID Installer cert (for 3rd party installers).

Be sure it's NOT the Mac Developer Distribution (for use on the Mac Apple Store) or Developer ID Application. Yes, the naming here is really subtle and confusing, it's not just you.

You should see:

Status: signed by a developer certificate issued by Apple (Development)
   Signed with a trusted timestamp on: 2024-12-06 18:12:45 +0000
   Certificate Chain:
    1. 3rd Party Mac Developer Installer: Your Name (2LEGIT2QUIT)

Submission log is not yet available or submissionId does not exist

notarytool log can sometimes report this. This just means notarizing isn't complete yet.

Make sure you are using the --wait option on xcrun notarytool submit, especially in CI. This will hang the command until processing is complete and a response comes back.

Record not found

This can mean nothing is in actually in the .pkg.

Check the file size to be sure all of your plugins are actually in there. I got this error once and then realize my final file was something impossibly small, like 24Kb.

Timestamp

"The signature does not include a secure timestamp."

This means you forgot to add --timestamp when originally code signing the executables.

Start selling through Moonbase

Sign up today to get started selling your software through Moonbase.