diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 7c88a6d811..52e9517562 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -183,6 +183,7 @@ injector.requireCommand("run|ios", "./commands/run"); injector.requireCommand("run|android", "./commands/run"); injector.requireCommand("run|vision", "./commands/run"); injector.requireCommand("run|visionos", "./commands/run"); +injector.requireCommand("run|catalyst", "./commands/run"); injector.requireCommand("typings", "./commands/typings"); injector.requireCommand("preview", "./commands/preview"); @@ -198,6 +199,7 @@ injector.requireCommand("build|ios", "./commands/build"); injector.requireCommand("build|android", "./commands/build"); injector.requireCommand("build|vision", "./commands/build"); injector.requireCommand("build|visionos", "./commands/build"); +injector.requireCommand("build|catalyst", "./commands/build"); injector.requireCommand("deploy", "./commands/deploy"); injector.requireCommand("embed", "./commands/embedding/embed"); diff --git a/lib/commands/build.ts b/lib/commands/build.ts index 7216e8a3fc..f77ed7685f 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -278,3 +278,61 @@ export class BuildVisionOsCommand extends BuildIosCommand implements ICommand { injector.registerCommand("build|vision", BuildVisionOsCommand); injector.registerCommand("build|visionos", BuildVisionOsCommand); + +/** + * Builds the iOS target against the macOS SDK as a Mac Catalyst app. + */ +export class BuildCatalystCommand extends BuildIosCommand implements ICommand { + constructor( + protected $options: IOptions, + $errors: IErrors, + $projectData: IProjectData, + $platformsDataService: IPlatformsDataService, + $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, + $buildController: IBuildController, + $platformValidationService: IPlatformValidationService, + $logger: ILogger, + $buildDataService: IBuildDataService, + protected $migrateController: IMigrateController, + ) { + super( + $options, + $errors, + $projectData, + $platformsDataService, + $devicePlatformsConstants, + $buildController, + $platformValidationService, + $logger, + $buildDataService, + $migrateController, + ); + } + + public async execute(args: string[]): Promise { + await this.executeCore([ + this.$devicePlatformsConstants.Catalyst.toLowerCase(), + ]); + } + + public async canExecute(args: string[]): Promise { + const platform = this.$devicePlatformsConstants.Catalyst; + if (!this.$options.force) { + await this.$migrateController.validate({ + projectDir: this.$projectData.projectDir, + platforms: [platform], + }); + } + + super.validatePlatform(platform); + + let canExecute = await super.canExecuteCommandBase(platform); + if (canExecute) { + canExecute = await super.validateArgs(args, platform); + } + + return canExecute; + } +} + +injector.registerCommand("build|catalyst", BuildCatalystCommand); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index 8b7e789c1b..28f7a2552f 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -30,20 +30,20 @@ export class RunCommandBase implements ICommand { private $migrateController: IMigrateController, private $options: IOptions, private $projectData: IProjectData, - private $keyCommandHelper: IKeyCommandHelper + private $keyCommandHelper: IKeyCommandHelper, ) {} public allowedParameters: ICommandParameter[] = []; public async execute(args: string[]): Promise { await this.$liveSyncCommandHelper.executeCommandLiveSync( this.platform, - this.liveSyncCommandHelperAdditionalOptions + this.liveSyncCommandHelperAdditionalOptions, ); if (process.env.NS_IS_INTERACTIVE) { this.$keyCommandHelper.attachKeyCommands( this.platform as IKeyCommandPlatform, - "run" + "run", ); } } @@ -64,7 +64,7 @@ export class RunCommandBase implements ICommand { : [ this.$devicePlatformsConstants.Android, this.$devicePlatformsConstants.iOS, - ]; + ]; if (!this.$options.force) { await this.$migrateController.validate({ @@ -100,7 +100,7 @@ export class RunIosCommand implements ICommand { protected $injector: IInjector, protected $options: IOptions, protected $platformValidationService: IPlatformValidationService, - protected $projectDataService: IProjectDataService + protected $projectDataService: IProjectDataService, ) {} public async execute(args: string[]): Promise { @@ -113,11 +113,11 @@ export class RunIosCommand implements ICommand { if ( !this.$platformValidationService.isPlatformSupportedForOS( this.platform, - projectData + projectData, ) ) { this.$errors.fail( - `Applications for platform ${this.platform} can not be built on this OS` + `Applications for platform ${this.platform} can not be built on this OS`, ); } @@ -127,7 +127,7 @@ export class RunIosCommand implements ICommand { this.$options.provision, this.$options.teamId, projectData, - this.platform.toLowerCase() + this.platform.toLowerCase(), )); return result; } @@ -154,7 +154,7 @@ export class RunAndroidCommand implements ICommand { private $injector: IInjector, private $options: IOptions, private $platformValidationService: IPlatformValidationService, - private $projectData: IProjectData + private $projectData: IProjectData, ) {} public async execute(args: string[]): Promise { @@ -167,11 +167,11 @@ export class RunAndroidCommand implements ICommand { if ( !this.$platformValidationService.isPlatformSupportedForOS( this.$devicePlatformsConstants.Android, - this.$projectData + this.$projectData, ) ) { this.$errors.fail( - `Applications for platform ${this.$devicePlatformsConstants.Android} can not be built on this OS` + `Applications for platform ${this.$devicePlatformsConstants.Android} can not be built on this OS`, ); } @@ -190,7 +190,7 @@ export class RunAndroidCommand implements ICommand { this.$options.provision, this.$options.teamId, this.$projectData, - this.$devicePlatformsConstants.Android.toLowerCase() + this.$devicePlatformsConstants.Android.toLowerCase(), ); } } @@ -208,7 +208,7 @@ export class RunVisionOSCommand extends RunIosCommand { protected $injector: IInjector, protected $options: IOptions, protected $platformValidationService: IPlatformValidationService, - protected $projectDataService: IProjectDataService + protected $projectDataService: IProjectDataService, ) { super( $devicePlatformsConstants, @@ -216,10 +216,39 @@ export class RunVisionOSCommand extends RunIosCommand { $injector, $options, $platformValidationService, - $projectDataService + $projectDataService, ); } } injector.registerCommand("run|vision", RunVisionOSCommand); injector.registerCommand("run|visionos", RunVisionOSCommand); + +/** + * Runs the Mac Catalyst build of the app on this machine. + */ +export class RunCatalystCommand extends RunIosCommand { + public get platform(): string { + return this.$devicePlatformsConstants.Catalyst; + } + + constructor( + protected $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, + protected $errors: IErrors, + protected $injector: IInjector, + protected $options: IOptions, + protected $platformValidationService: IPlatformValidationService, + protected $projectDataService: IProjectDataService, + ) { + super( + $devicePlatformsConstants, + $errors, + $injector, + $options, + $platformValidationService, + $projectDataService, + ); + } +} + +injector.registerCommand("run|catalyst", RunCatalystCommand); diff --git a/lib/common/bootstrap.ts b/lib/common/bootstrap.ts index 9905f452b9..9f17c625c2 100644 --- a/lib/common/bootstrap.ts +++ b/lib/common/bootstrap.ts @@ -82,6 +82,10 @@ injector.require( "iOSSimulatorDiscovery", "./mobile/mobile-core/ios-simulator-discovery" ); +injector.require( + "macCatalystDeviceDiscovery", + "./mobile/mobile-core/mac-catalyst-discovery" +); injector.require( "androidDeviceDiscovery", "./mobile/mobile-core/android-device-discovery" diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts index e5db0a23c1..5327dba757 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -138,6 +138,13 @@ declare global { destroyAllSockets(): Promise; } + interface IMacCatalystDevice extends IDevice { + /** + * Absolute path of the built .app bundle this device runs and syncs into. + */ + applicationBundlePath: string; + } + interface IAndroidDevice extends IDevice { adb: Mobile.IDeviceAndroidDebugBridge; init(): Promise; @@ -1204,6 +1211,7 @@ declare global { isAndroidPlatform(platform: string): boolean; isiOSPlatform(platform: string): boolean; isvisionOSPlatform(platform: string): boolean; + isCatalystPlatform(platform: string): boolean; isApplePlatform(platform: string): boolean; normalizePlatformName(platform: string): string; validatePlatformName(platform: string): string; @@ -1248,10 +1256,12 @@ declare global { iOS: string; Android: string; visionOS: string; + Catalyst: string; isiOS(value: string): boolean; isAndroid(value: string): boolean; isvisionOS(value: string): boolean; + isCatalyst(value: string): boolean; } interface IDeviceApplication { diff --git a/lib/common/mobile/device-platforms-constants.ts b/lib/common/mobile/device-platforms-constants.ts index a02eb88ffe..633a4f0d46 100644 --- a/lib/common/mobile/device-platforms-constants.ts +++ b/lib/common/mobile/device-platforms-constants.ts @@ -6,6 +6,8 @@ export class DevicePlatformsConstants public iOS = "iOS"; public Android = "Android"; public visionOS = "visionOS"; + // Not a runtime of its own: iOS rebuilt against the macOS SDK. + public Catalyst = "Catalyst"; public isiOS(value: string) { return value.toLowerCase() === this.iOS.toLowerCase(); @@ -18,5 +20,9 @@ export class DevicePlatformsConstants public isvisionOS(value: string) { return value.toLowerCase() === this.visionOS.toLowerCase(); } + + public isCatalyst(value: string) { + return value.toLowerCase() === this.Catalyst.toLowerCase(); + } } injector.register("devicePlatformsConstants", DevicePlatformsConstants); diff --git a/lib/common/mobile/mac/mac-catalyst-application-manager.ts b/lib/common/mobile/mac/mac-catalyst-application-manager.ts new file mode 100644 index 0000000000..c51e4e1387 --- /dev/null +++ b/lib/common/mobile/mac/mac-catalyst-application-manager.ts @@ -0,0 +1,145 @@ +import { ChildProcess } from "child_process"; +import * as path from "path"; +import { ApplicationManagerBase } from "../application-manager-base"; +import { hook } from "../../helpers"; +import { cache } from "../../decorators"; +import { IOS_LOG_PREDICATE } from "../../constants"; +import { + IChildProcess, + IDictionary, + IFileSystem, + IHooksService, +} from "../../declarations"; +import { IOptions } from "../../../declarations"; + +export class MacCatalystApplicationManager extends ApplicationManagerBase { + private logProcess: ChildProcess = null; + + constructor( + private device: Mobile.IMacCatalystDevice, + private $childProcess: IChildProcess, + private $fs: IFileSystem, + private $options: IOptions, + protected $deviceLogProvider: Mobile.IDeviceLogProvider, + private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, + $logger: ILogger, + $hooksService: IHooksService, + ) { + super($logger, $hooksService, $deviceLogProvider); + } + + public async getInstalledApplications(): Promise { + // Installed only means the build produced the bundle in place. + return this.$fs.exists(this.device.applicationBundlePath) + ? [this.device.deviceInfo.identifier] + : []; + } + + @hook("install") + public async installApplication(packageFilePath: string): Promise { + // No install step: just record where the build put the bundle. + this.device.applicationBundlePath = packageFilePath; + } + + public async uninstallApplication(appIdentifier: string): Promise { + await this.stopApplication({ + appId: appIdentifier, + projectName: null, + projectDir: null, + }); + } + + public async startApplication( + appData: Mobile.IStartApplicationData, + ): Promise { + await this.setDeviceLogData(appData); + + // -n forces a fresh instance instead of activating the running copy. + await this.$childProcess.spawnFromEvent( + "open", + ["-n", this.device.applicationBundlePath], + "close", + ); + } + + public async stopApplication( + appData: Mobile.IApplicationData, + ): Promise { + try { + // Anchored so it never matches our own log stream process. + await this.$childProcess.spawnFromEvent( + "pkill", + ["-f", `^${this.getExecutablePath()}$`], + "close", + ); + } catch (err) { + // pkill exits non-zero when no process matched. + this.$logger.trace( + `Nothing to stop for ${appData.appId}. More info: ${err.message}`, + ); + } + } + + public async getDebuggableApps(): Promise< + Mobile.IDeviceApplicationInformation[] + > { + return []; + } + + public async getDebuggableAppViews( + appIdentifiers: string[], + ): Promise> { + return null; + } + + private getExecutablePath(): string { + return path.join( + this.device.applicationBundlePath, + "Contents", + "MacOS", + path.basename(this.device.applicationBundlePath, ".app"), + ); + } + + private async setDeviceLogData( + appData: Mobile.IApplicationData, + ): Promise { + this.$deviceLogProvider.setProjectNameForDevice( + this.device.deviceInfo.identifier, + appData.projectName, + ); + this.$deviceLogProvider.setProjectDirForDevice( + this.device.deviceInfo.identifier, + appData.projectDir, + ); + + if (!this.$options.justlaunch) { + this.startDeviceLog(); + } + } + + @cache() + private startDeviceLog(): void { + // Narrowed to this app and the runtime, else system noise floods. + this.logProcess = this.$childProcess.spawn("/usr/bin/log", [ + "stream", + "--style", + "compact", + "--level", + "debug", + "--predicate", + `processImagePath == "${this.getExecutablePath()}" AND ${IOS_LOG_PREDICATE}`, + ]); + + const action = (data: Buffer | string) => { + this.$deviceLogProvider.logData( + data.toString(), + this.$devicePlatformsConstants.Catalyst, + this.device.deviceInfo.identifier, + ); + }; + + this.logProcess.stdout?.on("data", action); + this.logProcess.stderr?.on("data", action); + } +} diff --git a/lib/common/mobile/mac/mac-catalyst-device.ts b/lib/common/mobile/mac/mac-catalyst-device.ts new file mode 100644 index 0000000000..71f9a5e4c7 --- /dev/null +++ b/lib/common/mobile/mac/mac-catalyst-device.ts @@ -0,0 +1,100 @@ +import * as os from "os"; +import * as path from "path"; +import { MacCatalystApplicationManager } from "./mac-catalyst-application-manager"; +import { MacCatalystFileSystem } from "./mac-catalyst-file-system"; +import * as constants from "../../constants"; +import { DeviceConnectionType } from "../../../constants"; +import { IInjector } from "../../definitions/yok"; +import { IOptions } from "../../../declarations"; +import { IBuildDataService } from "../../../definitions/build"; +import { IPlatformsDataService } from "../../../definitions/platform"; +import { IProjectDataService } from "../../../definitions/project"; + +export const MAC_CATALYST_DEVICE_IDENTIFIER = "mac-catalyst"; + +/** + * The Mac exposed as a device so build, deploy and LiveSync drive a Catalyst app. + */ +export class MacCatalystDevice implements Mobile.IMacCatalystDevice { + public applicationManager: Mobile.IDeviceApplicationManager; + public fileSystem: Mobile.IDeviceFileSystem; + public deviceInfo: Mobile.IDeviceInfo; + + private _applicationBundlePath: string = null; + + constructor( + private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, + private $injector: IInjector, + private $options: IOptions, + private $buildDataService: IBuildDataService, + private $platformsDataService: IPlatformsDataService, + private $projectDataService: IProjectDataService, + ) { + this.applicationManager = this.$injector.resolve( + MacCatalystApplicationManager, + { device: this }, + ); + this.fileSystem = this.$injector.resolve(MacCatalystFileSystem); + this.deviceInfo = { + imageIdentifier: MAC_CATALYST_DEVICE_IDENTIFIER, + identifier: MAC_CATALYST_DEVICE_IDENTIFIER, + displayName: os.hostname(), + model: "Mac", + version: os.release(), + vendor: "Apple", + platform: this.$devicePlatformsConstants.Catalyst, + status: constants.CONNECTED_STATUS, + errorHelp: null, + isTablet: false, + type: constants.DeviceTypes.Device, + connectionTypes: [DeviceConnectionType.Local], + }; + } + + /** + * Path of the built .app, falling back to where the build would put it. + */ + public get applicationBundlePath(): string { + if (!this._applicationBundlePath) { + this._applicationBundlePath = this.getBuiltApplicationBundlePath(); + } + + return this._applicationBundlePath; + } + + public set applicationBundlePath(bundlePath: string) { + this._applicationBundlePath = bundlePath; + } + + public get isEmulator(): boolean { + return false; + } + + public get isOnlyWiFiConnected(): boolean { + return false; + } + + public async openDeviceLogStream(): Promise { + // Nothing to attach to until the application manager launches the app. + return; + } + + private getBuiltApplicationBundlePath(): string { + const projectData = this.$projectDataService.getProjectData(); + const platform = this.$devicePlatformsConstants.Catalyst; + const platformData = this.$platformsDataService.getPlatformData( + platform.toLowerCase(), + projectData, + ); + const buildData = this.$buildDataService.getBuildData( + projectData.projectDir, + platform, + this.$options.argv, + ); + + return path.join( + platformData.getBuildOutputPath(buildData), + `${projectData.projectName}.app`, + ); + } +} diff --git a/lib/common/mobile/mac/mac-catalyst-file-system.ts b/lib/common/mobile/mac/mac-catalyst-file-system.ts new file mode 100644 index 0000000000..d33af5817a --- /dev/null +++ b/lib/common/mobile/mac/mac-catalyst-file-system.ts @@ -0,0 +1,101 @@ +import * as path from "path"; +import * as shelljs from "shelljs"; +import * as _ from "lodash"; +import { IFileSystem, IStringDictionary } from "../../declarations"; + +/** + * Local file operations on the Mac Catalyst app bundle, which is a plain directory. + */ +export class MacCatalystFileSystem implements Mobile.IDeviceFileSystem { + constructor( + private $fs: IFileSystem, + private $logger: ILogger, + ) {} + + public async listFiles(devicePath: string): Promise { + return this.$fs.readDirectory(devicePath); + } + + public async getFile( + deviceFilePath: string, + appIdentifier: string, + outputFilePath?: string, + ): Promise { + if (outputFilePath) { + shelljs.cp("-f", deviceFilePath, outputFilePath); + } + } + + public async getFileContent( + deviceFilePath: string, + appIdentifier: string, + ): Promise { + return this.$fs.readText(deviceFilePath); + } + + public async putFile( + localFilePath: string, + deviceFilePath: string, + appIdentifier: string, + ): Promise { + shelljs.cp("-f", localFilePath, deviceFilePath); + } + + public async deleteFile( + deviceFilePath: string, + appIdentifier: string, + ): Promise { + shelljs.rm("-rf", deviceFilePath); + } + + public async transferFiles( + deviceAppData: Mobile.IDeviceAppData, + localToDevicePaths: Mobile.ILocalToDevicePathData[], + ): Promise { + await Promise.all( + _.map(localToDevicePaths, (localToDevicePathData) => + this.transferFile( + localToDevicePathData.getLocalPath(), + localToDevicePathData.getDevicePath(), + ), + ), + ); + return localToDevicePaths; + } + + public async transferDirectory( + deviceAppData: Mobile.IDeviceAppData, + localToDevicePaths: Mobile.ILocalToDevicePathData[], + projectFilesPath: string, + ): Promise { + const destinationPath = await deviceAppData.getDeviceProjectRootPath(); + this.$logger.trace( + `Transferring from ${projectFilesPath} to ${destinationPath}`, + ); + this.$fs.ensureDirectoryExists(destinationPath); + shelljs.cp("-Rf", path.join(projectFilesPath, "*"), destinationPath); + return localToDevicePaths; + } + + public async transferFile( + localFilePath: string, + deviceFilePath: string, + ): Promise { + this.$logger.trace( + `Transferring from ${localFilePath} to ${deviceFilePath}`, + ); + if (this.$fs.getFsStats(localFilePath).isDirectory()) { + this.$fs.ensureDirectoryExists(deviceFilePath); + } else { + this.$fs.ensureDirectoryExists(path.dirname(deviceFilePath)); + shelljs.cp("-f", localFilePath, deviceFilePath); + } + } + + public updateHashesOnDevice( + hashes: IStringDictionary, + appIdentifier: string, + ): Promise { + return; + } +} diff --git a/lib/common/mobile/mobile-core/devices-service.ts b/lib/common/mobile/mobile-core/devices-service.ts index 15bf11787f..6b584fa158 100644 --- a/lib/common/mobile/mobile-core/devices-service.ts +++ b/lib/common/mobile/mobile-core/devices-service.ts @@ -56,6 +56,7 @@ export class DevicesService private $emulatorHelper: Mobile.IEmulatorHelper, private $prompter: IPrompter, private $devicePlatformsConstants: Mobile.IDevicePlatformsConstants, + private $macCatalystDeviceDiscovery: Mobile.IDeviceDiscovery, ) { super(); this.attachToKnownDeviceDiscoveryEvents(); @@ -64,6 +65,7 @@ export class DevicesService this.$iOSDeviceDiscovery, this.$androidDeviceDiscovery, this.$iOSSimulatorDiscovery, + this.$macCatalystDeviceDiscovery, ]; } @@ -324,6 +326,7 @@ export class DevicesService this.$iOSSimulatorDiscovery, this.$iOSDeviceDiscovery, this.$androidDeviceDiscovery, + this.$macCatalystDeviceDiscovery, ].forEach(this.attachToDeviceDiscoveryEvents.bind(this)); } diff --git a/lib/common/mobile/mobile-core/ios-device-discovery.ts b/lib/common/mobile/mobile-core/ios-device-discovery.ts index d7b5a210e9..80405f375b 100644 --- a/lib/common/mobile/mobile-core/ios-device-discovery.ts +++ b/lib/common/mobile/mobile-core/ios-device-discovery.ts @@ -23,6 +23,8 @@ export class IOSDeviceDiscovery extends DeviceDiscovery { options && options.platform && (!this.$mobileHelper.isApplePlatform(options.platform) || + // Catalyst runs on this machine, not over usbmux. + this.$mobileHelper.isCatalystPlatform(options.platform) || options.emulator) ) { return; diff --git a/lib/common/mobile/mobile-core/mac-catalyst-discovery.ts b/lib/common/mobile/mobile-core/mac-catalyst-discovery.ts new file mode 100644 index 0000000000..5c47d7309d --- /dev/null +++ b/lib/common/mobile/mobile-core/mac-catalyst-discovery.ts @@ -0,0 +1,39 @@ +import { DeviceDiscovery } from "./device-discovery"; +import { MacCatalystDevice } from "../mac/mac-catalyst-device"; +import { IInjector } from "../../definitions/yok"; +import { IHostInfo } from "../../declarations"; +import { injector } from "../../yok"; + +export class MacCatalystDeviceDiscovery extends DeviceDiscovery { + private isDeviceAdded = false; + + constructor( + private $injector: IInjector, + private $hostInfo: IHostInfo, + private $mobileHelper: Mobile.IMobileHelper, + ) { + super(); + } + + public async startLookingForDevices( + options?: Mobile.IDeviceLookingOptions, + ): Promise { + // Only one Mac to run on, and it is this machine. + if (!this.$hostInfo.isDarwin || this.isDeviceAdded) { + return; + } + + if ( + !options || + !options.platform || + !this.$mobileHelper.isCatalystPlatform(options.platform) + ) { + return; + } + + this.addDevice(this.$injector.resolve(MacCatalystDevice)); + this.isDeviceAdded = true; + } +} + +injector.register("macCatalystDeviceDiscovery", MacCatalystDeviceDiscovery); diff --git a/lib/common/mobile/mobile-helper.ts b/lib/common/mobile/mobile-helper.ts index 666f2ffae9..bb0b4077a0 100644 --- a/lib/common/mobile/mobile-helper.ts +++ b/lib/common/mobile/mobile-helper.ts @@ -21,6 +21,7 @@ export class MobileHelper implements Mobile.IMobileHelper { this.$devicePlatformsConstants.iOS, this.$devicePlatformsConstants.Android, this.$devicePlatformsConstants.visionOS, + this.$devicePlatformsConstants.Catalyst, ]; } @@ -48,8 +49,20 @@ export class MobileHelper implements Mobile.IMobileHelper { ); } + public isCatalystPlatform(platform: string): boolean { + return !!( + platform && + this.$devicePlatformsConstants.Catalyst.toLowerCase() === + platform.toLowerCase() + ); + } + public isApplePlatform(platform: string): boolean { - return this.isiOSPlatform(platform) || this.isvisionOSPlatform(platform); + return ( + this.isiOSPlatform(platform) || + this.isvisionOSPlatform(platform) || + this.isCatalystPlatform(platform) + ); } public normalizePlatformName(platform: string): string { @@ -59,6 +72,8 @@ export class MobileHelper implements Mobile.IMobileHelper { return "iOS"; } else if (this.isvisionOSPlatform(platform)) { return "visionOS"; + } else if (this.isCatalystPlatform(platform)) { + return "Catalyst"; } return undefined; diff --git a/lib/constants.ts b/lib/constants.ts index 1c50e69870..196e1e8d88 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -360,12 +360,14 @@ export const enum PlatformTypes { ios = "ios", android = "android", visionos = "visionos", + catalyst = "catalyst", } export type SupportedPlatform = | PlatformTypes.ios | PlatformTypes.android - | PlatformTypes.visionos; + | PlatformTypes.visionos + | PlatformTypes.catalyst; export const PODFILE_NAME = "Podfile"; diff --git a/lib/controllers/platform-controller.ts b/lib/controllers/platform-controller.ts index 6e572bb149..4142f81332 100644 --- a/lib/controllers/platform-controller.ts +++ b/lib/controllers/platform-controller.ts @@ -188,10 +188,10 @@ export class PlatformController implements IPlatformController { projectData: IProjectData, nativePrepare: INativePrepare ): boolean { - const platformName = platformData.platformNameLowerCase; - const hasPlatformDirectory = this.$fs.exists( - path.join(projectData.platformsDir, platformName) - ); + // Mac Catalyst reports iOS but prepares into platforms/catalyst. + const platformDirectory = platformData.projectRoot; + const platformName = path.basename(platformDirectory); + const hasPlatformDirectory = this.$fs.exists(platformDirectory); const shouldAddNativePlatform = !nativePrepare || !nativePrepare.skipNativePrepare; @@ -206,9 +206,8 @@ export class PlatformController implements IPlatformController { (shouldAddNativePlatform && requiresNativePlatformAdd); if (hasPlatformDirectory && !shouldAddPlatform) { - const platformDirectoryItemCount = this.$fs.readDirectory( - path.join(projectData.platformsDir, platformName) - ).length; + const platformDirectoryItemCount = + this.$fs.readDirectory(platformDirectory).length; // 2 is a magic number to approximate a valid platform folder // any valid platform should contain at least 2 files/folders diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index 5ef34172d5..b0ea888df2 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -97,6 +97,11 @@ export class PrepareController extends EventEmitter { return this.prepareCore(prepareData, projectData); } + // Catalyst reports iOS in platform data, but events must say catalyst. + private getRequestedPlatform(prepareData: IPrepareData): string { + return prepareData.platform.toLowerCase(); + } + public async stopWatchers( projectDir: string, platform: string, @@ -220,18 +225,14 @@ export class PrepareController extends EventEmitter { projectData: IProjectData, prepareData: IPrepareData, ): Promise { + const requestedPlatform = this.getRequestedPlatform(prepareData); + if (!this.watchersData[projectData.projectDir]) { this.watchersData[projectData.projectDir] = {}; } - if ( - !this.watchersData[projectData.projectDir][ - platformData.platformNameLowerCase - ] - ) { - this.watchersData[projectData.projectDir][ - platformData.platformNameLowerCase - ] = { + if (!this.watchersData[projectData.projectDir][requestedPlatform]) { + this.watchersData[projectData.projectDir][requestedPlatform] = { nativeFilesWatcher: null, hasWebpackCompilerProcess: false, prepareArguments: { @@ -253,7 +254,7 @@ export class PrepareController extends EventEmitter { prepareData, ); // -> start watcher + initial prepare const result = { - platform: platformData.platformNameLowerCase, + platform: requestedPlatform, hasNativeChanges, }; @@ -274,7 +275,7 @@ export class PrepareController extends EventEmitter { hasOnlyHotUpdateFiles: false, hasNativeChanges: result.hasNativeChanges, hmrData: null, - platform: platformData.platformNameLowerCase, + platform: requestedPlatform, }); } @@ -286,15 +287,14 @@ export class PrepareController extends EventEmitter { projectData: IProjectData, prepareData: IPrepareData, ): Promise { + const requestedPlatform = this.getRequestedPlatform(prepareData); + if ( - !this.watchersData[projectData.projectDir][ - platformData.platformNameLowerCase - ].hasWebpackCompilerProcess + !this.watchersData[projectData.projectDir][requestedPlatform] + .hasWebpackCompilerProcess ) { const handler = (data: any) => { - if ( - data.platform.toLowerCase() === platformData.platformNameLowerCase - ) { + if (data.platform.toLowerCase() === requestedPlatform) { if (this.isFileWatcherPaused()) return; this.emitPrepareEvent({ ...data, hasNativeChanges: false }); } @@ -307,7 +307,7 @@ export class PrepareController extends EventEmitter { ); this.watchersData[projectData.projectDir][ - platformData.platformNameLowerCase + requestedPlatform ].hasWebpackCompilerProcess = true; await this.$bundlerCompilerService.compileWithWatch( platformData, @@ -329,6 +329,7 @@ export class PrepareController extends EventEmitter { newNativeWatchStarted = await this.startNativeWatcher( platformData, projectData, + prepareData, ); } @@ -347,11 +348,13 @@ export class PrepareController extends EventEmitter { private async startNativeWatcher( platformData: IPlatformData, projectData: IProjectData, + prepareData: IPrepareData, ): Promise { + const requestedPlatform = this.getRequestedPlatform(prepareData); + if ( - this.watchersData[projectData.projectDir][ - platformData.platformNameLowerCase - ].nativeFilesWatcher + this.watchersData[projectData.projectDir][requestedPlatform] + .nativeFilesWatcher ) { return false; } @@ -383,14 +386,14 @@ export class PrepareController extends EventEmitter { hasOnlyHotUpdateFiles: false, hmrData: null, hasNativeChanges: true, - platform: platformData.platformNameLowerCase, + platform: requestedPlatform, }); } }, ); this.watchersData[projectData.projectDir][ - platformData.platformNameLowerCase + requestedPlatform ].nativeFilesWatcher = watcher; return true; 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 4dd9a85a6a..deda92a286 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -138,6 +138,8 @@ interface INsConfigIOS extends INsConfigPlaform { interface INSConfigVisionOS extends INsConfigIOS {} +interface INSConfigCatalyst extends INsConfigIOS {} + interface INsConfigAndroid extends INsConfigPlaform { v8Flags?: string; @@ -197,6 +199,7 @@ interface INsConfig { ios?: INsConfigIOS; android?: INsConfigAndroid; visionos?: INSConfigVisionOS; + catalyst?: INSConfigCatalyst; ignoredNativeDependencies?: string[]; hooks?: INsConfigHooks[]; projectName?: string; diff --git a/lib/device-path-provider.ts b/lib/device-path-provider.ts index 6995c41b31..adf8e32608 100644 --- a/lib/device-path-provider.ts +++ b/lib/device-path-provider.ts @@ -17,6 +17,21 @@ export class DevicePathProvider implements IDevicePathProvider { options: IDeviceProjectRootOptions ): Promise { let projectRoot = ""; + if (this.$mobileHelper.isCatalystPlatform(device.deviceInfo.platform)) { + projectRoot = (device).applicationBundlePath; + if (!projectRoot) { + this.$errors.fail("Unable to get application path on device."); + } + + // Catalyst keeps its payload under Contents/Resources, not the bundle root. + projectRoot = path.join(projectRoot, "Contents", "Resources"); + if (!options.getDirname) { + projectRoot = path.join(projectRoot, APP_FOLDER_NAME); + } + + return projectRoot; + } + if (this.$mobileHelper.isApplePlatform(device.deviceInfo.platform)) { projectRoot = device.isEmulator ? await this.$iOSSimResolver.iOSSim.getApplicationPath( diff --git a/lib/project-data.ts b/lib/project-data.ts index 277dbf32d1..77bc341e5a 100644 --- a/lib/project-data.ts +++ b/lib/project-data.ts @@ -326,6 +326,7 @@ export class ProjectData implements IProjectData { ios: "", android: "", visionos: "", + catalyst: "", }; } @@ -333,6 +334,8 @@ export class ProjectData implements IProjectData { ios: config.id, android: config.id, visionos: config.id, + // Mac Catalyst ships under the iOS bundle identifier by default. + catalyst: config.id, }; if (config.ios && config.ios.id) { @@ -344,6 +347,12 @@ export class ProjectData implements IProjectData { if (config.visionos && config.visionos.id) { identifier.visionos = config.visionos.id; } + if (config.ios && config.ios.id) { + identifier.catalyst = config.ios.id; + } + if (config.catalyst && config.catalyst.id) { + identifier.catalyst = config.catalyst.id; + } return identifier; } diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index 0a4bd8800e..94e9a500af 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -109,7 +109,7 @@ export class BundlerCompilerService prepareData: IPrepareData, ): Promise { return new Promise(async (resolve, reject) => { - if (this.bundlerProcesses[platformData.platformNameLowerCase]) { + if (this.bundlerProcesses[prepareData.platform.toLowerCase()]) { resolve(void 0); return; } @@ -236,7 +236,8 @@ export class BundlerCompilerService hash: (message as IBundlerEmitMessage).hash || "", fallbackFiles: [] as string[], }, - platform: platformData.platformNameLowerCase, + // Requested platform; Catalyst reports iOS in platform data. + platform: prepareData.platform.toLowerCase(), }; this.$logger.info( @@ -344,7 +345,8 @@ export class BundlerCompilerService hash: result.hash, fallbackFiles, }, - platform: platformData.platformNameLowerCase, + // Requested platform; Catalyst reports iOS in platform data. + platform: prepareData.platform.toLowerCase(), }; this.$logger.trace( @@ -367,7 +369,7 @@ export class BundlerCompilerService this.$logger.trace( `Unable to start ${projectData.bundler} process in watch mode. Error is: ${err}`, ); - delete this.bundlerProcesses[platformData.platformNameLowerCase]; + delete this.bundlerProcesses[prepareData.platform.toLowerCase()]; reject(err); }); @@ -384,7 +386,7 @@ export class BundlerCompilerService `Executing ${projectData.bundler} failed with exit code ${exitCode}.`, ); error.code = exitCode; - delete this.bundlerProcesses[platformData.platformNameLowerCase]; + delete this.bundlerProcesses[prepareData.platform.toLowerCase()]; reject(error); }); } catch (err) { @@ -399,7 +401,7 @@ export class BundlerCompilerService prepareData: IPrepareData, ): Promise { return new Promise(async (resolve, reject) => { - if (this.bundlerProcesses[platformData.platformNameLowerCase]) { + if (this.bundlerProcesses[prepareData.platform.toLowerCase()]) { resolve(); return; } @@ -415,7 +417,7 @@ export class BundlerCompilerService this.$logger.trace( `Unable to start ${projectData.bundler} process in non-watch mode. Error is: ${err}`, ); - delete this.bundlerProcesses[platformData.platformNameLowerCase]; + delete this.bundlerProcesses[prepareData.platform.toLowerCase()]; reject(err); }); @@ -426,7 +428,7 @@ export class BundlerCompilerService childProcess.pid.toString(), ); - delete this.bundlerProcesses[platformData.platformNameLowerCase]; + delete this.bundlerProcesses[prepareData.platform.toLowerCase()]; const exitCode = typeof arg === "number" ? arg : arg && arg.code; if (exitCode === 0) { // Non-watch Vite builds spawn the child with stdio:"inherit" @@ -577,6 +579,11 @@ export class BundlerCompilerService this.$options.hostProjectModuleName, USER_PROJECT_PLATFORMS_IOS: this.$options.hostProjectPath, }); + } else if (this.$mobileHelper.isCatalystPlatform(prepareData.platform)) { + // Bundler hardcodes platforms/ios; Catalyst prepares into platforms/catalyst. + Object.assign(options.env, { + USER_PROJECT_PLATFORMS_IOS: platformData.projectRoot, + }); } if (debugLog) { @@ -589,7 +596,7 @@ export class BundlerCompilerService options, ); - this.bundlerProcesses[platformData.platformNameLowerCase] = childProcess; + this.bundlerProcesses[prepareData.platform.toLowerCase()] = childProcess; await this.$cleanupService.addKillProcess(childProcess.pid.toString()); return childProcess; @@ -778,7 +785,8 @@ export class BundlerCompilerService prepareData: IPrepareData, ) { const { env } = prepareData; - const envData = Object.assign({}, env, { [platform.toLowerCase()]: true }); + const platformKey = platform.toLowerCase(); + const envData = Object.assign({}, env, { [platformKey]: true }); const appId = projectData.projectIdentifiers[platform]; const appPath = projectData.getAppDirectoryRelativePath(); @@ -1042,7 +1050,7 @@ export class BundlerCompilerService hash: lastHash || message.hash, fallbackFiles: [], }, - platform: platformData.platformNameLowerCase, + platform: prepareData.platform.toLowerCase(), }); } diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 550b7dc00d..872af71c4d 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -69,12 +69,23 @@ 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 && + injector.resolve("devicePlatformsConstants").isCatalyst(buildData.platform) + ) { + return CatalystPlatformSdkName; + } + const isvisionOS = injector .resolve("devicePlatformsConstants") .isvisionOS(buildData.platform); @@ -152,12 +163,16 @@ export class IOSProjectService (this._platformsDirCache !== projectData.platformsDir || this._platformOverrideCache !== currentOverride) ) { - const platform = this.$mobileHelper.normalizePlatformName( + const requestedPlatform = this.$mobileHelper.normalizePlatformName( this.$options.platformOverride ?? this.$devicePlatformsConstants.iOS, ); + // Mac Catalyst keeps every iOS convention; only the platform root differs. + const platform = this.$mobileHelper.isCatalystPlatform(requestedPlatform) + ? this.$devicePlatformsConstants.iOS + : requestedPlatform; const projectRoot = this.$options.hostProjectPath ? this.$options.hostProjectPath - : path.join(projectData.platformsDir, platform.toLowerCase()); + : path.join(projectData.platformsDir, requestedPlatform.toLowerCase()); const runtimePackage = this.$projectDataService.getRuntimePackage( projectData.projectDir, platform.toLowerCase() as constants.SupportedPlatform, @@ -184,10 +199,12 @@ export class IOSProjectService getValidBuildOutputData: ( buildOptions: IBuildData, ): IValidBuildOutputData => { + // Mac Catalyst produces a .app, never an .ipa. const forDevice = - !buildOptions || - !!buildOptions.buildForDevice || - !!buildOptions.buildForAppStore; + !this.$mobileHelper.isCatalystPlatform(requestedPlatform) && + (!buildOptions || + !!buildOptions.buildForDevice || + !!buildOptions.buildForAppStore); if (forDevice) { const ipaFileName = _.find( this.$fs.readDirectory( @@ -452,7 +469,20 @@ export class IOSProjectService this.emit(constants.BUILD_OUTPUT_EVENT_NAME, data); }; - if (buildData.buildForDevice) { + if (this.$devicePlatformsConstants.isCatalyst(buildData.platform)) { + // 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..1860772902 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,36 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { private $xcconfigService: IXcconfigService, ) {} + public getBuildForCatalystArgs( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig, + ): string[] { + // Forced because the runtime template only sets the legacy 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 +287,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, diff --git a/lib/services/platform/add-platform-service.ts b/lib/services/platform/add-platform-service.ts index b93aba4ea7..87ccb669e9 100644 --- a/lib/services/platform/add-platform-service.ts +++ b/lib/services/platform/add-platform-service.ts @@ -70,11 +70,10 @@ export class AddPlatformService implements IAddPlatformService { return frameworkVersion; } catch (err) { - const platformPath = path.join( - projectData.platformsDir, - platformData.platformNameLowerCase - ); - this.$fs.deleteDirectory(platformPath); + // hostProjectPath is the user's own project; never delete it. + if (!this.$options.hostProjectPath) { + this.$fs.deleteDirectory(platformData.projectRoot); + } throw err; } finally { spinner.stop(); @@ -185,15 +184,8 @@ export class AddPlatformService implements IAddPlatformService { frameworkDirPath: string, frameworkVersion: string ): Promise { - // here we should use ios OR android - const platformDir = - this.$options.hostProjectPath ?? - path.join( - projectData.platformsDir, - platformData.normalizedPlatformName.toLowerCase() - ); - - this.$fs.deleteDirectory(platformDir); + // projectRoot already accounts for hostProjectPath and platforms/catalyst. + this.$fs.deleteDirectory(platformData.projectRoot); //if iosHost - dont create project await platformData.platformProjectService.createProject( path.resolve(frameworkDirPath), diff --git a/lib/services/platforms-data-service.ts b/lib/services/platforms-data-service.ts index 0f0ac30848..59fb9d0801 100644 --- a/lib/services/platforms-data-service.ts +++ b/lib/services/platforms-data-service.ts @@ -16,6 +16,8 @@ export class PlatformsDataService implements IPlatformsDataService { ios: $iOSProjectService, android: $androidProjectService, visionos: $iOSProjectService, + // Mac Catalyst reuses the iOS project service. + catalyst: $iOSProjectService, }; } diff --git a/lib/services/project-data-service.ts b/lib/services/project-data-service.ts index 31e5a2a342..d8c1198342 100644 --- a/lib/services/project-data-service.ts +++ b/lib/services/project-data-service.ts @@ -625,6 +625,10 @@ export class ProjectDataService implements IProjectDataService { projectDir: string, platform: constants.SupportedPlatform, ): IBasePluginData { + // Mac Catalyst has no runtime of its own; it uses iOS. + if (platform === constants.PlatformTypes.catalyst) { + platform = constants.PlatformTypes.ios; + } const runtimePackage = this.$pluginsService .getDependenciesFromPackageJson(projectDir) .devDependencies.find((d) => {