At PSPDFKit, we consider ourselves early adopters of Mac Catalyst. 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 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, 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) 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.
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.
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 we need can be accessed via the API without issues.
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.
We use match 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
readonly true type 'appstore' git_url 'firstname.lastname@example.org: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 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, 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.
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.
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
To build our apps, we use
build_mac_app, which are platform-specific aliases for gym, 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. On the Mac, TestFlight isn’t available, so we just use
deliver to upload the binary.
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
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
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.
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.