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.devMaking 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 usecom.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"
Don't be scared off by all the variables like ${{ env.BUNDLE_ID }}
in this article's examples. In CI, for example on GitHub Actions, this is how you would refer to them. If you are working locally, mentally replace the whole ${{ VARIABLE }}
syntax with whatever your value is.
Here's some hand-holding with the arguments:
--identifier
is your bundle id (likecom.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 thedistribution.xml
when putting things together.
You already signed the executables. Signing the intermediate packages here is not necessary — you'll be signing the final "flat" `.pkg`.
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'll see a lot of messy, cargo-culted distribution.xml files on the internet. For example, the choice id doesn't have to include a bundle id, that just makes things messy!
You can see a full example of a distribution.xml in my Pamplejuce JUCE template project.
Note: Pamplejuce uses a template, so all variables like ${BUNDLE_ID}
are replaced (via envsub
) before being used. Feel free to keep it simple and hardcoded!
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"
Make sure you are using the Developer ID Installer
cert (for 3rd party installers) and not Developer ID Application
(which you used for code signing).
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"
This uploads your .pkg
to Apple and there's a bit of processing and scanning time involved. Sometimes the service goes down. So, you might want to specify a timeout in CI. In GitHub actions, you would set the step to something like timeout-minutes: 5
.
Customizing the installer
You can customize to specify things like a background image, a readme and license, like so:
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.