diff --git a/lib/data/build-data.ts b/lib/data/build-data.ts index 0159031936..7b30fc7534 100644 --- a/lib/data/build-data.ts +++ b/lib/data/build-data.ts @@ -33,6 +33,7 @@ export class IOSBuildData extends BuildData implements IiOSBuildData { public buildForAppStore: boolean; public iCloudContainerEnvironment: string; public hostProjectPath: string; + public catalyst: boolean; constructor(projectDir: string, platform: string, data: any) { super(projectDir, platform, data); @@ -43,6 +44,7 @@ export class IOSBuildData extends BuildData implements IiOSBuildData { this.buildForAppStore = data.buildForAppStore; this.iCloudContainerEnvironment = data.iCloudContainerEnvironment; this.hostProjectPath = data.hostProjectPath; + this.catalyst = data.catalyst; } } diff --git a/lib/definitions/ios.d.ts b/lib/definitions/ios.d.ts index 60c3430bbb..7940fc7e33 100644 --- a/lib/definitions/ios.d.ts +++ b/lib/definitions/ios.d.ts @@ -39,6 +39,11 @@ declare global { projectData: IProjectData, buildConfig: IBuildConfig, ): Promise; + buildForCatalyst( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig, + ): Promise; } interface IosSPMPackageBase { @@ -110,6 +115,11 @@ declare global { projectData: IProjectData, buildConfig: IBuildConfig, ): Promise; + getBuildForCatalystArgs( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig, + ): string[]; getXcodeProjectArgs( platformData: IPlatformData, projectData: IProjectData, diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index a582ea98fe..5851d578cd 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -659,6 +659,10 @@ interface IiOSBuildConfig * Code sign identity used for build. If not set iPhone Developer is used as a default when building for device. */ codeSignIdentity?: string; + /** + * Build a native macOS app with Mac Catalyst instead of an iOS app. + */ + catalyst?: boolean; } /** diff --git a/lib/options.ts b/lib/options.ts index 3a60114ec2..04003ef85c 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -99,6 +99,7 @@ export class Options { framework: { type: OptionType.String, hasSensitiveValue: false }, frameworkVersion: { type: OptionType.String, hasSensitiveValue: false }, forDevice: { type: OptionType.Boolean, hasSensitiveValue: false }, + catalyst: { type: OptionType.Boolean, hasSensitiveValue: false }, iCloudContainerEnvironment: { type: OptionType.String, hasSensitiveValue: false, diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index ff88261087..aea924f36b 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -69,12 +69,20 @@ export const DevicePlatformSdkName = "iphoneos"; export const SimulatorPlatformSdkName = "iphonesimulator"; export const VisionDevicePlatformSdkName = "xros"; export const VisionSimulatorPlatformSdkName = "xrsimulator"; +// Not an SDK name — Xcode names the Mac Catalyst products directory +// `-maccatalyst`, and the build output path is derived from it. +export const CatalystPlatformSdkName = "maccatalyst"; const FRAMEWORK_EXTENSIONS = [".framework", ".xcframework"]; const getPlatformSdkName = (buildData: IBuildData): string => { const forDevice = !buildData || buildData.buildForDevice || buildData.buildForAppStore; + + if (buildData && (<{ catalyst?: boolean }>buildData).catalyst) { + return CatalystPlatformSdkName; + } + const isvisionOS = injector .resolve("devicePlatformsConstants") .isvisionOS(buildData.platform); @@ -452,7 +460,20 @@ export class IOSProjectService this.emit(constants.BUILD_OUTPUT_EVENT_NAME, data); }; - if (buildData.buildForDevice) { + if (buildData.catalyst) { + // Signing is handled by `-allowProvisioningUpdates`: Mac Catalyst needs a + // macOS provisioning profile, which the iOS signing service cannot pick. + await attachAwaitDetach( + constants.BUILD_OUTPUT_EVENT_NAME, + this.$childProcess, + handler, + this.$xcodebuildService.buildForCatalyst( + platformData, + projectData, + buildData, + ), + ); + } else if (buildData.buildForDevice) { await this.$iOSSigningService.setupSigningForDevice( projectRoot, projectData, diff --git a/lib/services/ios/xcodebuild-args-service.ts b/lib/services/ios/xcodebuild-args-service.ts index 9f6998b79c..446a5e71e5 100644 --- a/lib/services/ios/xcodebuild-args-service.ts +++ b/lib/services/ios/xcodebuild-args-service.ts @@ -12,6 +12,7 @@ import { IPlatformData } from "../../definitions/platform"; import { IFileSystem } from "../../common/declarations"; import { injector } from "../../common/yok"; import * as _ from "lodash"; +import * as semver from "semver"; import { DevicePlatformSdkName, @@ -21,6 +22,8 @@ import { } from "../ios-project-service"; export class XcodebuildArgsService implements IXcodebuildArgsService { + private static readonly MIN_CATALYST_DEPLOYMENT_TARGET = "13.1"; + constructor( private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, private $devicesService: Mobile.IDevicesService, @@ -30,6 +33,39 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { private $xcconfigService: IXcconfigService, ) {} + public getBuildForCatalystArgs( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig, + ): string[] { + // Mac Catalyst is a variant of the iOS platform rather than a platform of + // its own: the same target is rebuilt against the macOS SDK with the + // `-macabi` triple. `SUPPORTS_MACCATALYST` has to be forced because the + // runtime template only enables the legacy `SUPPORTS_UIKITFORMAC` alias. + return [ + "-destination", + "platform=macOS,variant=Mac Catalyst", + "build", + "-configuration", + buildConfig.release ? Configurations.Release : Configurations.Debug, + "-allowProvisioningUpdates", + "SUPPORTS_MACCATALYST=YES", + // no `-sdk` here: the destination already selects macOS + the Mac Catalyst + // variant, and forcing an SDK on top of it makes xcodebuild pick iphoneos + "BUILD_DIR=" + path.join(platformData.projectRoot, constants.BUILD_DIR), + "SHARED_PRECOMPS_DIR=" + + path.join(platformData.projectRoot, constants.BUILD_DIR, "sharedpch"), + ] + .concat( + // the deployment target is re-added below, clamped to what Catalyst supports + this + .getXcodeProjectArgs(platformData, projectData) + .filter((arg) => !arg.startsWith("IPHONEOS_DEPLOYMENT_TARGET=")), + ) + .concat(this.getCatalystDeploymentTargetArgs(projectData)) + .concat(this.getBuildLoggingArgs()); + } + public async getBuildForSimulatorArgs( platformData: IPlatformData, projectData: IProjectData, @@ -254,6 +290,44 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { return this.$logger.getLevel() === "INFO" ? ["-quiet"] : []; } + /** + * Mac Catalyst starts at iOS 13.1, so a project that still targets an older iOS + * cannot be built as-is. Raise the deployment target for the Catalyst build only + * rather than failing — the iOS build keeps whatever the app has chosen. + * `MACCATALYST_DEPLOYMENT_TARGET` is passed alongside because the runtime's + * metadata generator reads it and older runtimes crash when it is unset. + */ + private getCatalystDeploymentTargetArgs(projectData: IProjectData): string[] { + const buildSettingsFilePath = path.join( + projectData.appResourcesDirectoryPath, + this.$devicePlatformsConstants.iOS, + constants.BUILD_XCCONFIG_FILE_NAME, + ); + const projectDeploymentTarget = this.$xcconfigService.readPropertyValue( + buildSettingsFilePath, + "IPHONEOS_DEPLOYMENT_TARGET", + ); + const minimum = XcodebuildArgsService.MIN_CATALYST_DEPLOYMENT_TARGET; + let deploymentTarget = projectDeploymentTarget; + + if ( + !deploymentTarget || + semver.lt(semver.coerce(deploymentTarget), semver.coerce(minimum)) + ) { + if (deploymentTarget) { + this.$logger.warn( + `Mac Catalyst requires iOS ${minimum} or higher. Building the Mac Catalyst app with IPHONEOS_DEPLOYMENT_TARGET=${minimum} instead of the project's ${deploymentTarget}.`, + ); + } + deploymentTarget = minimum; + } + + return [ + `IPHONEOS_DEPLOYMENT_TARGET=${deploymentTarget}`, + `MACCATALYST_DEPLOYMENT_TARGET=${deploymentTarget}`, + ]; + } + private getBuildCommonArgs( platformData: IPlatformData, projectData: IProjectData, diff --git a/lib/services/ios/xcodebuild-service.ts b/lib/services/ios/xcodebuild-service.ts index 4f8308dfb5..6cc916ae3f 100644 --- a/lib/services/ios/xcodebuild-service.ts +++ b/lib/services/ios/xcodebuild-service.ts @@ -48,6 +48,22 @@ export class XcodebuildService implements IXcodebuildService { }); } + public async buildForCatalyst( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig + ): Promise { + const args = this.$xcodebuildArgsService.getBuildForCatalystArgs( + platformData, + projectData, + buildConfig + ); + await this.$xcodebuildCommandService.executeCommand(args, { + cwd: platformData.projectRoot, + stdio: buildConfig.buildOutputStdio, + }); + } + public async buildForAppStore( platformData: IPlatformData, projectData: IProjectData,