Automating Mac Catalyst Distribution with fastlane

Automating Mac Catalyst Distribution with fastlane

At PSPDFKit, we consider ourselves early adopters of Mac Catalyst(opens in a new tab). We added support for it to our SDK in 2019, just shortly after the technology was first made available, and we also used it to bring our PDF Viewer app to the Mac(opens in a new tab) around the same time.

One of the compromises we had to make to get our products out this early was to accept that we’d have to manually build and distribute PDF Viewer for Mac instead of leveraging our CI. fastlane(opens in a new tab), our automation tool of choice for those tasks, simply didn’t support Mac Catalyst applications at that time. It took quite a bit of effort, a bunch of fastlane updates, and some help from the fastlane team (thanks Josh(opens in a new tab)) to finally put together a configuration that works reliably for a shared iOS and Mac Catalyst application. In this post, I’ll share some details about our setup, which you can use as inspiration for your own Mac Catalyst projects.

App Builds

Below, you can see the key parts of our PDF Viewer Fastfile related to building and app distribution for both iOS and Mac Catalyst. We’ll go over the interesting bits section by section:

default_platform :ios
require 'dotenv'
Dotenv.overload('.env.local')
api_key =
app_store_connect_api_key(
key_id: ENV['APP_STORE_CONNECT_KEY_ID'],
issuer_id: ENV['APP_STORE_CONNECT_ISSUER_ID'],
key_content: ENV['APP_STORE_CONNECT_API_KEY_B64'],
is_key_content_base64: true,
in_house: false
)
desc 'Synchronizes certificates / profiles using via the App Store Connect API. Optionally creates new ones.'
private_lane :match_configuration do
readonly =
UI.confirm(
"Read only? ('y' doesn't create new certificates/profiles... 'n' creates/updates if needed)"
)
match(api_key: api_key, readonly: readonly, verbose: true)
end
require_relative('../../fastlane/actions/update_project_from_match.rb')
desc 'Updates project signing settings for manual code signing.'
private_lane :update_for_manual_siging do
update_project_from_match(
project: 'Viewer.xcodeproj',
configuration: 'Release',
code_sign_style: 'Manual',
code_sign_identity: 'Apple Distribution'
)
end
platform :ios do
desc 'Synchronizes certificates / profiles and optionally creates new ones.'
lane :sync_signing do
match_configuration
end
desc 'Synchronizes distribution certificates / profiles and updates project settings.'
lane :prepare_manual_signing do
match
update_for_manual_siging
end
desc 'Builds the application.'
lane :compile_app do
unlock_keychain(path: 'login', password: ENV['CI_USER_PASSWORD'])
build_ios_app
end
desc 'Builds and uploads a new build to App Store Connect for TestFlight testing.'
lane :build_and_upload_app do
prepare_manual_signing
compile_app
pilot(skip_waiting_for_build_processing: false)
upload_symbols_to_crashlytics
end
end
platform :mac do
desc 'Synchronizes certificates / profiles and optionally creates new ones.'
lane :sync_signing do
match_configuration
end
desc 'Synchronizes distribution certificates / profiles and updates project settings.'
lane :prepare_manual_signing do
match
update_for_manual_siging
end
desc 'Build app'
lane :compile_app do
unlock_keychain(path: 'login', password: ENV['CI_USER_PASSWORD'])
build_mac_app(
destination: 'platform=macOS,arch=x86_64,variant=Mac Catalyst',
installer_cert_name:
'3rd Party Mac Developer Installer: PSPDFKit GmbH (XXXXXXXXXX)'
)
end
desc 'Builds and uploads a new build to App Store Connect.'
lane :build_and_upload_app do
prepare_manual_signing
compile_app
deliver
upload_symbols_to_crashlytics
end
end

The file has three main sections. At the top we have some common helpers, which are then referenced in two platform-specific sections — one for iOS and one for the Mac. The first line defines iOS as the default platform, which means it’ll be used when we omit the platform specifier. If you look closely, you’ll see that the iOS and macOS sections are in fact very similar. Both define the same helpers and really only differ in some configuration options and the choice of final distribution method.

For the sake of brevity, the Fastfile above omits some less interesting helpers, as well as metadata upload, the latter of which we’ll cover separately in a subsequent section.

API Keys and Environment

The first few lines of our Fastfile deal with API credentials for App Store Connect access. We recently switched our fastlane configuration from the legacy Apple ID-based system to the official App Store Connect API. By doing so, we avoided issues with 2FA authentication and increased overall reliability of our setup. Fortunately, all the App Store functionality(opens in a new tab) we need can be accessed via the API without issues.

If you want to do the same, I recommend this blog post(opens in a new tab) from Alastair Hendricks(opens in a new tab) to get you started.

API credentials are stored securely on our CI agents and included as environment variables. To allow use of fastlane on local development machines, we import an .env.local file, which is ignored by Git and can contain secrets like the API keys.

Signing

We use match(opens in a new tab) to manage certificates and provisioning profiles for our production builds. For a time, we tried to instead leverage automatic signing, but it turned out to be more trouble than it’s worth. We want to be in charge of certificate updates and only update them explicitly, so we defined sync_signing lanes for both iOS and Mac, which in turn use the match_configuration helper, which needs to be run on a development machine.

You might be confused about the configuration looking the same for iOS and Mac. The conditional configuration here is in our Matchfile, which is another separate configuration file fastlane can use. By selecting either the iOS or Mac sync_signing lane, we implicitly pick the corresponding configuration from the Matchfile:

readonly true
type 'appstore'
git_url '[email protected]:PSPDFKit/certificates.git'
keychain_password ENV['CI_USER_PASSWORD']
for_platform :ios do
platform 'ios'
app_identifier %w[
com.pspdfkit.viewer
com.pspdfkit.viewer.stickers
com.pspdfkit.viewer.PDF-Actions
]
end
for_platform :mac do
platform 'catalyst'
app_identifier 'com.pspdfkit.viewer'
additional_cert_types %w[mac_installer_distribution]
end

As you can see, we use the same bundle identifier for the iOS and Mac versions of PDF Viewer. This wasn’t always the case. As early adopters of Mac Catalyst, we initially had to use maccatalyst-prefixed identifiers(opens in a new tab) on the Mac, which complicated code signing and made sharing in-app purchases difficult. Even though chaining the bundle ID essentially means shipping a brand-new application(opens in a new tab), we determined that resolving both of those issues was worth the effort. The iOS version also contains a sticker pack and action extension, which is why it lists multiple bundle IDs.

The update_project_from_match action invoked from prepare_manual_signing is a custom action specific to our setup. Our projects are configured to use automatic code signing for all build configurations to ease local development and testing. The action modifies the Xcode project to use manual code signing instead. We have to do this instead of setting xcargs, because they get applied to all targets and cause some resources that don’t need signing to be signed. Simply setting up the Release configuration to always use manual code signing should be the better option for most projects.

Build and Upload

The main entry points for our setup used on CI are the two build_and_upload_app lanes, which upload the build products generated by the compile_app lanes.

To build our apps, we use build_ios_app and build_mac_app, which are platform-specific aliases for gym(opens in a new tab), fastlane’s building and packaging helper. The helpers define some platform-specific configuration options. To get things working, we also had to explicitly set some parameters for the Mac version. Everything else is defined in our Gymfile and will typically be the same for an application that’s distributed to iOS and Mac Catalyst:

scheme 'Viewer'
configuration 'Release'
clean true
export_method 'app-store'
buildlog_path 'fastlane/logs'
output_directory './'

The distribution step is similar as well, with one key difference: On iOS, we want to make our build directly available for internal TestFlight testing, which is why we use pilot(opens in a new tab). On the Mac, TestFlight isn’t available, so we just use deliver(opens in a new tab) to upload the binary.

Metadata

The same approach to application binary distribution can also be extended to other resources supported by fastlane, such as metadata:

platform :ios do
desc 'Updates the text metadata while preserving existing screenshots from App Store Connect.'
lane :upload_text do
deliver(
skip_metadata: false, skip_screenshots: true, skip_binary_upload: true
)
end
end
platform :mac do
desc 'Updates the text metadata while preserving existing screenshots from App Store Connect'
lane :upload_text do
deliver(
skip_metadata: false, skip_screenshots: true, skip_binary_upload: true
)
end
end

Additional options can then be set in a separate Deliverfile. The key is to set different directories for the iOS app and the Mac app and to follow the usual directory structure for deliver(opens in a new tab):

submit_for_review false
skip_metadata true
skip_screenshots true
skip_binary_upload false
for_platform :ios do
platform 'ios'
metadata_path './fastlane/metadata'
screenshots_path './fastlane/screenshots'
end
for_platform :mac do
platform 'osx'
metadata_path './fastlane/metadata-mac'
screenshots_path './fastlane/screenshots-mac'
run_precheck_before_submit false
end

Use

Invoking fastlane will display a handy selection UI that lists iOS and Mac OS lanes. To execute one of the platform-specific lanes, we just pass the platform-prefixed lane to the command — e.g. fastlane ios build_and_upload_app or fastlane mac build_and_upload_app. If we omit the platform, we use iOS, due to our default_platform configuration option. The build_and_upload_app app commands are also exactly what our CI uses.

Conclusion

As you can see, automating Mac Catalyst distribution — something that was tricky to get working in the past — has become pretty straightforward with recent fastlane updates. All it takes are some platform-specific lanes and a few well-placed configuration options. It’s exciting to see how the tooling around Mac Catalyst is evolving as Mac Catalyst is becoming more of a mainstream option for Mac development. In that sense, I hope this article makes it even easier for you to make the decision to give Mac Catalyst a try.

Matej Bukovinski

Matej Bukovinski

CTO

Matej is a software engineering leader from Slovenia. He began his career freelancing and contributing to open source software. Later, he joined Nutrient, where he played a key role in creating its initial products and teams, eventually taking over as the company’s Chief Technology Officer. Outside of work, Matej enjoys playing tennis, skiing, and traveling.

Explore related topics

FREE TRIAL Ready to get started?