From b5d667080fac90fb4d32c5fb846e7469e3e0bd1e Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Tue, 11 Aug 2026 17:19:31 +0200 Subject: [PATCH 1/5] feat(macos): run and build Mac Catalyst apps with `ns run macos` Adds macOS as a supported platform. A Mac Catalyst app is the iOS app rebuilt against the macOS SDK, so it keeps every iOS convention -- App_Resources/iOS, the iOS runtime package, each plugin's platforms/ios folder, the iOS Podfile, the iOS bundle -- and diverges only in the directory it prepares into, platforms/macos, and the SDK it builds against. That is the whole design: iOSProjectService reports iOS as the platform name so every iOS convention falls out for free, and projectRoot is the single place macOS differs. Two call sites rebuilt the platform directory from the platform name rather than reading projectRoot, which would have made a macOS build probe and delete platforms/ios; both now use projectRoot, which already resolves hostProjectPath the same way. The Mac is modelled as a device so build, deploy and LiveSync drive it through the existing pipeline. Everything it does is local: the .app is a directory on this machine, so the file system is a plain copy, install only records the built bundle, launch is `open -n`, and the log stream is `log stream` narrowed to the app and the runtime. Watch-mode prepare events are stamped with the platform the caller asked for rather than the platform data's name. run-controller pairs an event with a device by comparing the two, so for Catalyst -- iOS platform data, macOS device -- a file change recompiled but reached no device. The non-watch path already reported the requested platform; watchers and bundler processes are now keyed by it too, since stopWatchers and stopBundlerCompiler are called with it. Nothing changes for ios/android/visionos, where both strings are identical. Co-Authored-By: Claude Opus 5 --- lib/bootstrap.ts | 2 + lib/commands/build.ts | 58 +++++++ lib/commands/run.ts | 29 ++++ lib/common/bootstrap.ts | 4 + lib/common/definitions/mobile.d.ts | 10 ++ .../mobile/device-platforms-constants.ts | 6 + .../mac/mac-catalyst-application-manager.ts | 145 ++++++++++++++++++ lib/common/mobile/mac/mac-catalyst-device.ts | 100 ++++++++++++ .../mobile/mac/mac-catalyst-file-system.ts | 101 ++++++++++++ .../mobile/mobile-core/devices-service.ts | 3 + .../mobile-core/ios-device-discovery.ts | 2 + .../mobile-core/mac-catalyst-discovery.ts | 39 +++++ lib/common/mobile/mobile-helper.ts | 17 +- lib/constants.ts | 4 +- lib/controllers/platform-controller.ts | 13 +- lib/controllers/prepare-controller.ts | 47 +++--- lib/definitions/ios.d.ts | 10 ++ lib/definitions/project.d.ts | 3 + lib/device-path-provider.ts | 15 ++ lib/project-data.ts | 9 ++ .../bundler/bundler-compiler-service.ts | 30 ++-- lib/services/ios-project-service.ts | 42 ++++- lib/services/ios/xcodebuild-args-service.ts | 71 +++++++++ lib/services/ios/xcodebuild-service.ts | 16 ++ lib/services/platform/add-platform-service.ts | 20 +-- lib/services/platforms-data-service.ts | 2 + lib/services/project-data-service.ts | 4 + 27 files changed, 740 insertions(+), 62 deletions(-) create mode 100644 lib/common/mobile/mac/mac-catalyst-application-manager.ts create mode 100644 lib/common/mobile/mac/mac-catalyst-device.ts create mode 100644 lib/common/mobile/mac/mac-catalyst-file-system.ts create mode 100644 lib/common/mobile/mobile-core/mac-catalyst-discovery.ts diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 7c88a6d811..8a232e9dd9 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|macos", "./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|macos", "./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..fab616d06a 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 BuildMacOsCommand 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.macOS.toLowerCase(), + ]); + } + + public async canExecute(args: string[]): Promise { + const platform = this.$devicePlatformsConstants.macOS; + 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|macos", BuildMacOsCommand); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index 8b7e789c1b..e5495277bf 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -223,3 +223,32 @@ export class RunVisionOSCommand extends RunIosCommand { injector.registerCommand("run|vision", RunVisionOSCommand); injector.registerCommand("run|visionos", RunVisionOSCommand); + +/** + * Runs the Mac Catalyst build of the app on this machine. + */ +export class RunMacOSCommand extends RunIosCommand { + public get platform(): string { + return this.$devicePlatformsConstants.macOS; + } + + 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|macos", RunMacOSCommand); 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..009e6e7073 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; + ismacOSPlatform(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; + macOS: string; isiOS(value: string): boolean; isAndroid(value: string): boolean; isvisionOS(value: string): boolean; + ismacOS(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..5951242343 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 macOS = "macOS"; 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 ismacOS(value: string) { + return value.toLowerCase() === this.macOS.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..ba6e3e0e15 --- /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.macOS, + 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..102a41e8f6 --- /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.macOS, + 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.macOS; + 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..8ff1fef7bd 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) || + // macOS runs on this machine, not over usbmux. + this.$mobileHelper.ismacOSPlatform(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..5dc10e7f96 --- /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.ismacOSPlatform(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..c32e7ff64e 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.macOS, ]; } @@ -48,8 +49,20 @@ export class MobileHelper implements Mobile.IMobileHelper { ); } + public ismacOSPlatform(platform: string): boolean { + return !!( + platform && + this.$devicePlatformsConstants.macOS.toLowerCase() === + platform.toLowerCase() + ); + } + public isApplePlatform(platform: string): boolean { - return this.isiOSPlatform(platform) || this.isvisionOSPlatform(platform); + return ( + this.isiOSPlatform(platform) || + this.isvisionOSPlatform(platform) || + this.ismacOSPlatform(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.ismacOSPlatform(platform)) { + return "macOS"; } return undefined; diff --git a/lib/constants.ts b/lib/constants.ts index 1c50e69870..35821fbdab 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -360,12 +360,14 @@ export const enum PlatformTypes { ios = "ios", android = "android", visionos = "visionos", + macos = "macos", } export type SupportedPlatform = | PlatformTypes.ios | PlatformTypes.android - | PlatformTypes.visionos; + | PlatformTypes.visionos + | PlatformTypes.macos; export const PODFILE_NAME = "Podfile"; diff --git a/lib/controllers/platform-controller.ts b/lib/controllers/platform-controller.ts index 6e572bb149..8d866bde6b 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/macos. + 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..d47d41d692 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 macos. + 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..52eaa0d865 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 INSConfigMacOS extends INsConfigIOS {} + interface INsConfigAndroid extends INsConfigPlaform { v8Flags?: string; @@ -197,6 +199,7 @@ interface INsConfig { ios?: INsConfigIOS; android?: INsConfigAndroid; visionos?: INSConfigVisionOS; + macos?: INSConfigMacOS; ignoredNativeDependencies?: string[]; hooks?: INsConfigHooks[]; projectName?: string; diff --git a/lib/device-path-provider.ts b/lib/device-path-provider.ts index 6995c41b31..2f2ba34d4c 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.ismacOSPlatform(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..37f9e9e6ef 100644 --- a/lib/project-data.ts +++ b/lib/project-data.ts @@ -326,6 +326,7 @@ export class ProjectData implements IProjectData { ios: "", android: "", visionos: "", + macos: "", }; } @@ -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. + macos: 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.macos = config.ios.id; + } + if (config.macos && config.macos.id) { + identifier.macos = config.macos.id; + } return identifier; } diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index 0a4bd8800e..710ca1f2b3 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.ismacOSPlatform(prepareData.platform)) { + // Bundler hardcodes platforms/ios; Catalyst prepares into platforms/macos. + 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..798eb1a3dc 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").ismacOS(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.ismacOSPlatform(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.ismacOSPlatform(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.ismacOS(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..ad947a9095 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/macos. + 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..a52c907b5a 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. + macos: $iOSProjectService, }; } diff --git a/lib/services/project-data-service.ts b/lib/services/project-data-service.ts index 31e5a2a342..bf01980d32 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.macos) { + platform = constants.PlatformTypes.ios; + } const runtimePackage = this.$pluginsService .getDependenciesFromPackageJson(projectDir) .devDependencies.find((d) => { From 26860f89c5f11a1bda49ff064398a61970b77547 Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Tue, 11 Aug 2026 17:39:11 +0200 Subject: [PATCH 2/5] refactor(catalyst): name the platform catalyst rather than macos `ns run macos` is being taken by a separate effort that builds a native macOS app against a macOS runtime. That is a different product from a Mac Catalyst build -- the iOS app rebuilt against the macOS SDK -- so this one takes the name that says what it actually is: `ns build catalyst`, `ns run catalyst`, preparing into platforms/catalyst. Only the platform identifier changes; the build, device and LiveSync behaviour is untouched. Co-Authored-By: Claude Opus 5 --- lib/bootstrap.ts | 4 +-- lib/commands/build.ts | 8 ++--- lib/commands/run.ts | 34 +++++++++---------- lib/common/definitions/mobile.d.ts | 6 ++-- .../mobile/device-platforms-constants.ts | 6 ++-- .../mac/mac-catalyst-application-manager.ts | 2 +- lib/common/mobile/mac/mac-catalyst-device.ts | 4 +-- .../mobile-core/ios-device-discovery.ts | 4 +-- .../mobile-core/mac-catalyst-discovery.ts | 2 +- lib/common/mobile/mobile-helper.ts | 12 +++---- lib/constants.ts | 4 +-- lib/controllers/platform-controller.ts | 2 +- lib/controllers/prepare-controller.ts | 2 +- lib/definitions/project.d.ts | 4 +-- lib/device-path-provider.ts | 2 +- lib/project-data.ts | 10 +++--- .../bundler/bundler-compiler-service.ts | 4 +-- lib/services/ios-project-service.ts | 8 ++--- lib/services/platform/add-platform-service.ts | 2 +- lib/services/platforms-data-service.ts | 2 +- lib/services/project-data-service.ts | 2 +- 21 files changed, 62 insertions(+), 62 deletions(-) diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 8a232e9dd9..52e9517562 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -183,7 +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|macos", "./commands/run"); +injector.requireCommand("run|catalyst", "./commands/run"); injector.requireCommand("typings", "./commands/typings"); injector.requireCommand("preview", "./commands/preview"); @@ -199,7 +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|macos", "./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 fab616d06a..f77ed7685f 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -282,7 +282,7 @@ injector.registerCommand("build|visionos", BuildVisionOsCommand); /** * Builds the iOS target against the macOS SDK as a Mac Catalyst app. */ -export class BuildMacOsCommand extends BuildIosCommand implements ICommand { +export class BuildCatalystCommand extends BuildIosCommand implements ICommand { constructor( protected $options: IOptions, $errors: IErrors, @@ -311,12 +311,12 @@ export class BuildMacOsCommand extends BuildIosCommand implements ICommand { public async execute(args: string[]): Promise { await this.executeCore([ - this.$devicePlatformsConstants.macOS.toLowerCase(), + this.$devicePlatformsConstants.Catalyst.toLowerCase(), ]); } public async canExecute(args: string[]): Promise { - const platform = this.$devicePlatformsConstants.macOS; + const platform = this.$devicePlatformsConstants.Catalyst; if (!this.$options.force) { await this.$migrateController.validate({ projectDir: this.$projectData.projectDir, @@ -335,4 +335,4 @@ export class BuildMacOsCommand extends BuildIosCommand implements ICommand { } } -injector.registerCommand("build|macos", BuildMacOsCommand); +injector.registerCommand("build|catalyst", BuildCatalystCommand); diff --git a/lib/commands/run.ts b/lib/commands/run.ts index e5495277bf..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,7 +216,7 @@ export class RunVisionOSCommand extends RunIosCommand { $injector, $options, $platformValidationService, - $projectDataService + $projectDataService, ); } } @@ -227,9 +227,9 @@ injector.registerCommand("run|visionos", RunVisionOSCommand); /** * Runs the Mac Catalyst build of the app on this machine. */ -export class RunMacOSCommand extends RunIosCommand { +export class RunCatalystCommand extends RunIosCommand { public get platform(): string { - return this.$devicePlatformsConstants.macOS; + return this.$devicePlatformsConstants.Catalyst; } constructor( @@ -251,4 +251,4 @@ export class RunMacOSCommand extends RunIosCommand { } } -injector.registerCommand("run|macos", RunMacOSCommand); +injector.registerCommand("run|catalyst", RunCatalystCommand); diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts index 009e6e7073..5327dba757 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -1211,7 +1211,7 @@ declare global { isAndroidPlatform(platform: string): boolean; isiOSPlatform(platform: string): boolean; isvisionOSPlatform(platform: string): boolean; - ismacOSPlatform(platform: string): boolean; + isCatalystPlatform(platform: string): boolean; isApplePlatform(platform: string): boolean; normalizePlatformName(platform: string): string; validatePlatformName(platform: string): string; @@ -1256,12 +1256,12 @@ declare global { iOS: string; Android: string; visionOS: string; - macOS: string; + Catalyst: string; isiOS(value: string): boolean; isAndroid(value: string): boolean; isvisionOS(value: string): boolean; - ismacOS(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 5951242343..633a4f0d46 100644 --- a/lib/common/mobile/device-platforms-constants.ts +++ b/lib/common/mobile/device-platforms-constants.ts @@ -7,7 +7,7 @@ export class DevicePlatformsConstants public Android = "Android"; public visionOS = "visionOS"; // Not a runtime of its own: iOS rebuilt against the macOS SDK. - public macOS = "macOS"; + public Catalyst = "Catalyst"; public isiOS(value: string) { return value.toLowerCase() === this.iOS.toLowerCase(); @@ -21,8 +21,8 @@ export class DevicePlatformsConstants return value.toLowerCase() === this.visionOS.toLowerCase(); } - public ismacOS(value: string) { - return value.toLowerCase() === this.macOS.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 index ba6e3e0e15..c51e4e1387 100644 --- a/lib/common/mobile/mac/mac-catalyst-application-manager.ts +++ b/lib/common/mobile/mac/mac-catalyst-application-manager.ts @@ -134,7 +134,7 @@ export class MacCatalystApplicationManager extends ApplicationManagerBase { const action = (data: Buffer | string) => { this.$deviceLogProvider.logData( data.toString(), - this.$devicePlatformsConstants.macOS, + this.$devicePlatformsConstants.Catalyst, this.device.deviceInfo.identifier, ); }; diff --git a/lib/common/mobile/mac/mac-catalyst-device.ts b/lib/common/mobile/mac/mac-catalyst-device.ts index 102a41e8f6..71f9a5e4c7 100644 --- a/lib/common/mobile/mac/mac-catalyst-device.ts +++ b/lib/common/mobile/mac/mac-catalyst-device.ts @@ -42,7 +42,7 @@ export class MacCatalystDevice implements Mobile.IMacCatalystDevice { model: "Mac", version: os.release(), vendor: "Apple", - platform: this.$devicePlatformsConstants.macOS, + platform: this.$devicePlatformsConstants.Catalyst, status: constants.CONNECTED_STATUS, errorHelp: null, isTablet: false, @@ -81,7 +81,7 @@ export class MacCatalystDevice implements Mobile.IMacCatalystDevice { private getBuiltApplicationBundlePath(): string { const projectData = this.$projectDataService.getProjectData(); - const platform = this.$devicePlatformsConstants.macOS; + const platform = this.$devicePlatformsConstants.Catalyst; const platformData = this.$platformsDataService.getPlatformData( platform.toLowerCase(), projectData, diff --git a/lib/common/mobile/mobile-core/ios-device-discovery.ts b/lib/common/mobile/mobile-core/ios-device-discovery.ts index 8ff1fef7bd..80405f375b 100644 --- a/lib/common/mobile/mobile-core/ios-device-discovery.ts +++ b/lib/common/mobile/mobile-core/ios-device-discovery.ts @@ -23,8 +23,8 @@ export class IOSDeviceDiscovery extends DeviceDiscovery { options && options.platform && (!this.$mobileHelper.isApplePlatform(options.platform) || - // macOS runs on this machine, not over usbmux. - this.$mobileHelper.ismacOSPlatform(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 index 5dc10e7f96..5c47d7309d 100644 --- a/lib/common/mobile/mobile-core/mac-catalyst-discovery.ts +++ b/lib/common/mobile/mobile-core/mac-catalyst-discovery.ts @@ -26,7 +26,7 @@ export class MacCatalystDeviceDiscovery extends DeviceDiscovery { if ( !options || !options.platform || - !this.$mobileHelper.ismacOSPlatform(options.platform) + !this.$mobileHelper.isCatalystPlatform(options.platform) ) { return; } diff --git a/lib/common/mobile/mobile-helper.ts b/lib/common/mobile/mobile-helper.ts index c32e7ff64e..bb0b4077a0 100644 --- a/lib/common/mobile/mobile-helper.ts +++ b/lib/common/mobile/mobile-helper.ts @@ -21,7 +21,7 @@ export class MobileHelper implements Mobile.IMobileHelper { this.$devicePlatformsConstants.iOS, this.$devicePlatformsConstants.Android, this.$devicePlatformsConstants.visionOS, - this.$devicePlatformsConstants.macOS, + this.$devicePlatformsConstants.Catalyst, ]; } @@ -49,10 +49,10 @@ export class MobileHelper implements Mobile.IMobileHelper { ); } - public ismacOSPlatform(platform: string): boolean { + public isCatalystPlatform(platform: string): boolean { return !!( platform && - this.$devicePlatformsConstants.macOS.toLowerCase() === + this.$devicePlatformsConstants.Catalyst.toLowerCase() === platform.toLowerCase() ); } @@ -61,7 +61,7 @@ export class MobileHelper implements Mobile.IMobileHelper { return ( this.isiOSPlatform(platform) || this.isvisionOSPlatform(platform) || - this.ismacOSPlatform(platform) + this.isCatalystPlatform(platform) ); } @@ -72,8 +72,8 @@ export class MobileHelper implements Mobile.IMobileHelper { return "iOS"; } else if (this.isvisionOSPlatform(platform)) { return "visionOS"; - } else if (this.ismacOSPlatform(platform)) { - return "macOS"; + } else if (this.isCatalystPlatform(platform)) { + return "Catalyst"; } return undefined; diff --git a/lib/constants.ts b/lib/constants.ts index 35821fbdab..196e1e8d88 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -360,14 +360,14 @@ export const enum PlatformTypes { ios = "ios", android = "android", visionos = "visionos", - macos = "macos", + catalyst = "catalyst", } export type SupportedPlatform = | PlatformTypes.ios | PlatformTypes.android | PlatformTypes.visionos - | PlatformTypes.macos; + | PlatformTypes.catalyst; export const PODFILE_NAME = "Podfile"; diff --git a/lib/controllers/platform-controller.ts b/lib/controllers/platform-controller.ts index 8d866bde6b..4142f81332 100644 --- a/lib/controllers/platform-controller.ts +++ b/lib/controllers/platform-controller.ts @@ -188,7 +188,7 @@ export class PlatformController implements IPlatformController { projectData: IProjectData, nativePrepare: INativePrepare ): boolean { - // Mac Catalyst reports iOS but prepares into platforms/macos. + // Mac Catalyst reports iOS but prepares into platforms/catalyst. const platformDirectory = platformData.projectRoot; const platformName = path.basename(platformDirectory); const hasPlatformDirectory = this.$fs.exists(platformDirectory); diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index d47d41d692..b0ea888df2 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -97,7 +97,7 @@ export class PrepareController extends EventEmitter { return this.prepareCore(prepareData, projectData); } - // Catalyst reports iOS in platform data, but events must say macos. + // Catalyst reports iOS in platform data, but events must say catalyst. private getRequestedPlatform(prepareData: IPrepareData): string { return prepareData.platform.toLowerCase(); } diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index 52eaa0d865..deda92a286 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -138,7 +138,7 @@ interface INsConfigIOS extends INsConfigPlaform { interface INSConfigVisionOS extends INsConfigIOS {} -interface INSConfigMacOS extends INsConfigIOS {} +interface INSConfigCatalyst extends INsConfigIOS {} interface INsConfigAndroid extends INsConfigPlaform { v8Flags?: string; @@ -199,7 +199,7 @@ interface INsConfig { ios?: INsConfigIOS; android?: INsConfigAndroid; visionos?: INSConfigVisionOS; - macos?: INSConfigMacOS; + catalyst?: INSConfigCatalyst; ignoredNativeDependencies?: string[]; hooks?: INsConfigHooks[]; projectName?: string; diff --git a/lib/device-path-provider.ts b/lib/device-path-provider.ts index 2f2ba34d4c..adf8e32608 100644 --- a/lib/device-path-provider.ts +++ b/lib/device-path-provider.ts @@ -17,7 +17,7 @@ export class DevicePathProvider implements IDevicePathProvider { options: IDeviceProjectRootOptions ): Promise { let projectRoot = ""; - if (this.$mobileHelper.ismacOSPlatform(device.deviceInfo.platform)) { + if (this.$mobileHelper.isCatalystPlatform(device.deviceInfo.platform)) { projectRoot = (device).applicationBundlePath; if (!projectRoot) { this.$errors.fail("Unable to get application path on device."); diff --git a/lib/project-data.ts b/lib/project-data.ts index 37f9e9e6ef..77bc341e5a 100644 --- a/lib/project-data.ts +++ b/lib/project-data.ts @@ -326,7 +326,7 @@ export class ProjectData implements IProjectData { ios: "", android: "", visionos: "", - macos: "", + catalyst: "", }; } @@ -335,7 +335,7 @@ export class ProjectData implements IProjectData { android: config.id, visionos: config.id, // Mac Catalyst ships under the iOS bundle identifier by default. - macos: config.id, + catalyst: config.id, }; if (config.ios && config.ios.id) { @@ -348,10 +348,10 @@ export class ProjectData implements IProjectData { identifier.visionos = config.visionos.id; } if (config.ios && config.ios.id) { - identifier.macos = config.ios.id; + identifier.catalyst = config.ios.id; } - if (config.macos && config.macos.id) { - identifier.macos = config.macos.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 710ca1f2b3..94e9a500af 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -579,8 +579,8 @@ export class BundlerCompilerService this.$options.hostProjectModuleName, USER_PROJECT_PLATFORMS_IOS: this.$options.hostProjectPath, }); - } else if (this.$mobileHelper.ismacOSPlatform(prepareData.platform)) { - // Bundler hardcodes platforms/ios; Catalyst prepares into platforms/macos. + } 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, }); diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 798eb1a3dc..872af71c4d 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -81,7 +81,7 @@ const getPlatformSdkName = (buildData: IBuildData): string => { if ( buildData && - injector.resolve("devicePlatformsConstants").ismacOS(buildData.platform) + injector.resolve("devicePlatformsConstants").isCatalyst(buildData.platform) ) { return CatalystPlatformSdkName; } @@ -167,7 +167,7 @@ export class IOSProjectService this.$options.platformOverride ?? this.$devicePlatformsConstants.iOS, ); // Mac Catalyst keeps every iOS convention; only the platform root differs. - const platform = this.$mobileHelper.ismacOSPlatform(requestedPlatform) + const platform = this.$mobileHelper.isCatalystPlatform(requestedPlatform) ? this.$devicePlatformsConstants.iOS : requestedPlatform; const projectRoot = this.$options.hostProjectPath @@ -201,7 +201,7 @@ export class IOSProjectService ): IValidBuildOutputData => { // Mac Catalyst produces a .app, never an .ipa. const forDevice = - !this.$mobileHelper.ismacOSPlatform(requestedPlatform) && + !this.$mobileHelper.isCatalystPlatform(requestedPlatform) && (!buildOptions || !!buildOptions.buildForDevice || !!buildOptions.buildForAppStore); @@ -469,7 +469,7 @@ export class IOSProjectService this.emit(constants.BUILD_OUTPUT_EVENT_NAME, data); }; - if (this.$devicePlatformsConstants.ismacOS(buildData.platform)) { + 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( diff --git a/lib/services/platform/add-platform-service.ts b/lib/services/platform/add-platform-service.ts index ad947a9095..87ccb669e9 100644 --- a/lib/services/platform/add-platform-service.ts +++ b/lib/services/platform/add-platform-service.ts @@ -184,7 +184,7 @@ export class AddPlatformService implements IAddPlatformService { frameworkDirPath: string, frameworkVersion: string ): Promise { - // projectRoot already accounts for hostProjectPath and platforms/macos. + // projectRoot already accounts for hostProjectPath and platforms/catalyst. this.$fs.deleteDirectory(platformData.projectRoot); //if iosHost - dont create project await platformData.platformProjectService.createProject( diff --git a/lib/services/platforms-data-service.ts b/lib/services/platforms-data-service.ts index a52c907b5a..59fb9d0801 100644 --- a/lib/services/platforms-data-service.ts +++ b/lib/services/platforms-data-service.ts @@ -17,7 +17,7 @@ export class PlatformsDataService implements IPlatformsDataService { android: $androidProjectService, visionos: $iOSProjectService, // Mac Catalyst reuses the iOS project service. - macos: $iOSProjectService, + catalyst: $iOSProjectService, }; } diff --git a/lib/services/project-data-service.ts b/lib/services/project-data-service.ts index bf01980d32..d8c1198342 100644 --- a/lib/services/project-data-service.ts +++ b/lib/services/project-data-service.ts @@ -626,7 +626,7 @@ export class ProjectDataService implements IProjectDataService { platform: constants.SupportedPlatform, ): IBasePluginData { // Mac Catalyst has no runtime of its own; it uses iOS. - if (platform === constants.PlatformTypes.macos) { + if (platform === constants.PlatformTypes.catalyst) { platform = constants.PlatformTypes.ios; } const runtimePackage = this.$pluginsService From db8f6516d692e2c1a3390e49c414c13cad5fcb5e Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Tue, 18 Aug 2026 14:25:49 +0200 Subject: [PATCH 3/5] chore: recognize catalyst as a platform (like visionOS) --- .../mac/mac-catalyst-application-manager.ts | 44 ++++++++++++++++++- .../bundler/bundler-compiler-service.ts | 6 +++ lib/services/ios-project-service.ts | 12 ++--- lib/services/plugins-service.ts | 5 ++- lib/services/project-changes-service.ts | 6 ++- 5 files changed, 63 insertions(+), 10 deletions(-) diff --git a/lib/common/mobile/mac/mac-catalyst-application-manager.ts b/lib/common/mobile/mac/mac-catalyst-application-manager.ts index c51e4e1387..696af3df12 100644 --- a/lib/common/mobile/mac/mac-catalyst-application-manager.ts +++ b/lib/common/mobile/mac/mac-catalyst-application-manager.ts @@ -1,7 +1,7 @@ import { ChildProcess } from "child_process"; import * as path from "path"; import { ApplicationManagerBase } from "../application-manager-base"; -import { hook } from "../../helpers"; +import { hook, sleep } from "../../helpers"; import { cache } from "../../decorators"; import { IOS_LOG_PREDICATE } from "../../constants"; import { @@ -64,12 +64,26 @@ export class MacCatalystApplicationManager extends ApplicationManagerBase { public async stopApplication( appData: Mobile.IApplicationData, + ): Promise { + const executablePath = this.getExecutablePath(); + await this.signalApplication(executablePath, "TERM", appData); + if (await this.waitForApplicationExit(executablePath)) { + return; + } + await this.signalApplication(executablePath, "KILL", appData); + await this.waitForApplicationExit(executablePath); + } + + private async signalApplication( + executablePath: string, + signal: string, + appData: Mobile.IApplicationData, ): Promise { try { // Anchored so it never matches our own log stream process. await this.$childProcess.spawnFromEvent( "pkill", - ["-f", `^${this.getExecutablePath()}$`], + [`-${signal}`, "-f", `^${executablePath}$`], "close", ); } catch (err) { @@ -80,6 +94,32 @@ export class MacCatalystApplicationManager extends ApplicationManagerBase { } } + // Returning before the old instance dies makes open -n spawn a duplicate. + private async waitForApplicationExit( + executablePath: string, + ): Promise { + for (let attempt = 0; attempt < 40; attempt++) { + if (!(await this.isApplicationRunning(executablePath))) { + return true; + } + await sleep(50); + } + return false; + } + + private async isApplicationRunning(executablePath: string): Promise { + try { + await this.$childProcess.spawnFromEvent( + "pgrep", + ["-f", `^${executablePath}$`], + "close", + ); + return true; + } catch (err) { + return false; + } + } + public async getDebuggableApps(): Promise< Mobile.IDeviceApplicationInformation[] > { diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index 94e9a500af..17f73d0580 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -11,6 +11,7 @@ import { PackageManagers, CONFIG_FILE_NAME_DISPLAY, VITE_DIST_FOLDER_NAME, + PlatformTypes, } from "../../constants"; import { IPackageManager, @@ -788,6 +789,11 @@ export class BundlerCompilerService const platformKey = platform.toLowerCase(); const envData = Object.assign({}, env, { [platformKey]: true }); + // Bundlers only know the base platforms, so Catalyst also flags ios. + if (this.$mobileHelper.isCatalystPlatform(platformKey)) { + envData[PlatformTypes.ios] = true; + } + const appId = projectData.projectIdentifiers[platform]; const appPath = projectData.getAppDirectoryRelativePath(); const appResourcesPath = projectData.getAppResourcesRelativeDirectoryPath(); diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 872af71c4d..b15f3e32a4 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -166,8 +166,10 @@ export class IOSProjectService 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) + // Mac Catalyst ships no runtime of its own, it reuses the iOS one. + const runtimePlatform = this.$mobileHelper.isCatalystPlatform( + requestedPlatform, + ) ? this.$devicePlatformsConstants.iOS : requestedPlatform; const projectRoot = this.$options.hostProjectPath @@ -175,13 +177,13 @@ export class IOSProjectService : path.join(projectData.platformsDir, requestedPlatform.toLowerCase()); const runtimePackage = this.$projectDataService.getRuntimePackage( projectData.projectDir, - platform.toLowerCase() as constants.SupportedPlatform, + runtimePlatform.toLowerCase() as constants.SupportedPlatform, ); this._platformData = { frameworkPackageName: runtimePackage.name, - normalizedPlatformName: platform, - platformNameLowerCase: platform.toLowerCase(), + normalizedPlatformName: requestedPlatform, + platformNameLowerCase: requestedPlatform.toLowerCase(), appDestinationDirectoryPath: path.join( projectRoot, projectData.projectName, diff --git a/lib/services/plugins-service.ts b/lib/services/plugins-service.ts index 43c843e59a..ce18b5abb2 100644 --- a/lib/services/plugins-service.ts +++ b/lib/services/plugins-service.ts @@ -640,7 +640,10 @@ This framework comes from ${dependencyName} plugin, which is installed multiple ); pluginData.isPlugin = !!cacheData.nativescript; pluginData.pluginPlatformsFolderPath = (platform: string) => { - if (this.$mobileHelper.isvisionOSPlatform(platform)) { + if ( + this.$mobileHelper.isvisionOSPlatform(platform) || + this.$mobileHelper.isCatalystPlatform(platform) + ) { platform = constants.PlatformTypes.ios; } return path.join( diff --git a/lib/services/project-changes-service.ts b/lib/services/project-changes-service.ts index 12e7574bfc..f382981b23 100644 --- a/lib/services/project-changes-service.ts +++ b/lib/services/project-changes-service.ts @@ -92,8 +92,10 @@ export class ProjectChangesService implements IProjectChangesService { if ( !this.$fs.exists(platformResourcesDir) && - platformData.platformNameLowerCase === - this.$devicePlatformsConstants.visionOS.toLowerCase() + (platformData.platformNameLowerCase === + this.$devicePlatformsConstants.visionOS.toLowerCase() || + platformData.platformNameLowerCase === + this.$devicePlatformsConstants.Catalyst.toLowerCase()) ) { platformResourcesDir = path.join( projectData.appResourcesDirectoryPath, From 6781fd59c1648c871af45ae9dcbe8a2e241e2a57 Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Tue, 18 Aug 2026 17:23:33 +0200 Subject: [PATCH 4/5] fix(bundler): resolve the configured bundler package and pass buildPath Projects overriding `webpackPackageName` (such as @akylas/nativescript-webpack) fell back to raw webpack/bin/webpack.js, which rejects the `--env.x` flags the CLI emits. Resolve the configured package so the modern bin is used instead. Restore `buildPath` in the bundler env. Without it the bundle is written outside the platform folder and the run never completes. Flag catalyst from the requested platform, since platform data reports iOS. Co-Authored-By: Claude Opus 5 --- lib/constants.ts | 1 + lib/contracts/project-data.ts | 2 ++ lib/project-data.ts | 8 +++++ .../bundler/bundler-compiler-service.ts | 32 +++++++++++++++---- test/stubs.ts | 8 +++++ 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index 5878a59997..6824034806 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -66,6 +66,7 @@ export const BUNDLE_DIR = "bundle"; export const RESOURCES_DIR = "res"; export const CONFIG_NS_FILE_NAME = "nsconfig.json"; export const CONFIG_NS_APP_RESOURCES_ENTRY = "appResourcesPath"; +export const CONFIG_NS_BUILD_ENTRY = "buildPath"; export const CONFIG_NS_APP_ENTRY = "appPath"; export const CONFIG_FILE_NAME_DISPLAY = "nativescript.config.(js|ts)"; export const CONFIG_FILE_NAME_JS = "nativescript.config.js"; diff --git a/lib/contracts/project-data.ts b/lib/contracts/project-data.ts index aea166166e..976d8a0ec6 100644 --- a/lib/contracts/project-data.ts +++ b/lib/contracts/project-data.ts @@ -89,4 +89,6 @@ export abstract class ProjectData { abstract getAppResourcesDirectoryPath(projectDir?: string): string; abstract getAppResourcesRelativeDirectoryPath(): string; + + abstract getBuildRelativeDirectoryPath(): string; } diff --git a/lib/project-data.ts b/lib/project-data.ts index 77bc341e5a..a3a5df4cf0 100644 --- a/lib/project-data.ts +++ b/lib/project-data.ts @@ -282,6 +282,14 @@ export class ProjectData implements IProjectData { return this.resolveToProjectDir(appRelativePath, projectDir); } + public getBuildRelativeDirectoryPath(): string { + if (this.nsConfig && this.nsConfig[constants.CONFIG_NS_BUILD_ENTRY]) { + return this.nsConfig[constants.CONFIG_NS_BUILD_ENTRY]; + } + + return constants.PLATFORMS_DIR_NAME; + } + public getAppDirectoryRelativePath(): string { if (this.nsConfig && this.nsConfig[constants.CONFIG_NS_APP_ENTRY]) { return this.nsConfig[constants.CONFIG_NS_APP_ENTRY]; diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index 17f73d0580..d44f6137db 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -789,20 +789,22 @@ export class BundlerCompilerService const platformKey = platform.toLowerCase(); const envData = Object.assign({}, env, { [platformKey]: true }); - // Bundlers only know the base platforms, so Catalyst also flags ios. - if (this.$mobileHelper.isCatalystPlatform(platformKey)) { - envData[PlatformTypes.ios] = true; + // Platform data reports iOS, so the flag comes from the request. + if (this.$mobileHelper.isCatalystPlatform(prepareData.platform)) { + envData[PlatformTypes.catalyst] = true; } const appId = projectData.projectIdentifiers[platform]; const appPath = projectData.getAppDirectoryRelativePath(); const appResourcesPath = projectData.getAppResourcesRelativeDirectoryPath(); + const buildPath = projectData.getBuildRelativeDirectoryPath(); Object.assign( envData, appId && { appId }, appPath && { appPath }, appResourcesPath && { appResourcesPath }, + buildPath && { buildPath }, { nativescriptLibPath: path.resolve( __dirname, @@ -1072,7 +1074,7 @@ export class BundlerCompilerService return path.resolve(packagePath, "bin", "vite.js"); } } else if (this.isModernBundler(projectData)) { - const packagePath = resolvePackagePath(`@nativescript/${bundler}`, { + const packagePath = resolvePackagePath(this.getBundlerPackageName(), { paths: [projectData.projectDir], }); @@ -1092,15 +1094,31 @@ export class BundlerCompilerService return path.resolve(packagePath, "bin", "webpack.js"); } + // Forks such as @akylas/nativescript-webpack replace the default package. + private getBundlerPackageName(): string { + const bundler = this.getBundler(); + if (bundler !== "webpack") { + return `@nativescript/${bundler}`; + } + + return this.$projectConfigService.getValue( + "webpackPackageName", + WEBPACK_PLUGIN_NAME, + ); + } + private isModernBundler(projectData: IProjectData): boolean { const bundler = this.getBundler(); switch (bundler) { case "rspack": return true; default: - const packageJSONPath = resolvePackageJSONPath(WEBPACK_PLUGIN_NAME, { - paths: [projectData.projectDir], - }); + const packageJSONPath = resolvePackageJSONPath( + this.getBundlerPackageName(), + { + paths: [projectData.projectDir], + }, + ); if (packageJSONPath) { const packageData = this.$fs.readJson(packageJSONPath); diff --git a/test/stubs.ts b/test/stubs.ts index 96bc5e2a33..8bec904a3b 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -720,6 +720,14 @@ export class ProjectDataStub implements IProjectData { return ""; } + public getBuildRelativeDirectoryPath(): string { + return "platforms"; + } + + public getIgnoredDependencies(platform?: string): string[] { + return []; + } + public getAppDirectoryPath(projectDir?: string): string { if (!projectDir) { projectDir = this.projectDir; From 4e1bbe48fd290e752217bb4c0a72e05a7e4deaee Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Tue, 18 Aug 2026 17:24:19 +0200 Subject: [PATCH 5/5] fix(ios/catalyst): make debug builds launch Report iOS as the platform name again. Ecosystem hooks and plugins switch on `normalizedPlatformName`, and an unknown value leaves them in a branch that never resolves, hanging the prepare. Only `projectRoot` stays catalyst specific. Read entitlements from App_Resources/iOS, since catalyst has no folder of its own. The merge silently produced nothing before, so the app group was dropped and the template sandbox entitlement survived. Disable the app sandbox for debug builds, which have no provisioning profile, and point metadata generation at the iOSSupport frameworks so UIKit resolves. Recreate the symlink layout of versioned frameworks after unzipping, as the runtime archives them flattened and codesign then rejects the bundle. Co-Authored-By: Claude Opus 5 --- lib/services/ios-entitlements-service.ts | 11 ++-- lib/services/ios-project-service.ts | 58 +++++++++++++++++++-- lib/services/ios/xcodebuild-args-service.ts | 5 ++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/lib/services/ios-entitlements-service.ts b/lib/services/ios-entitlements-service.ts index 3e61acf855..4aa55c19f0 100644 --- a/lib/services/ios-entitlements-service.ts +++ b/lib/services/ios-entitlements-service.ts @@ -20,11 +20,16 @@ export class IOSEntitlementsService { private getDefaultAppEntitlementsPath(projectData: IProjectData): string { const entitlementsName = IOSEntitlementsService.DefaultEntitlementsName; + const requestedPlatform = this.$mobileHelper.normalizePlatformName( + this.$options.platformOverride ?? this.$devicePlatformsConstants.iOS, + ); + // Catalyst has no App_Resources folder of its own, it reuses the iOS one. + const platform = this.$mobileHelper.isCatalystPlatform(requestedPlatform) + ? this.$devicePlatformsConstants.iOS + : requestedPlatform; const entitlementsPath = path.join( projectData.appResourcesDirectoryPath, - this.$mobileHelper.normalizePlatformName( - this.$options.platformOverride ?? this.$devicePlatformsConstants.iOS, - ), + platform, entitlementsName, ); return entitlementsPath; diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index bd7d082552..13ef1688b0 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -1,4 +1,5 @@ import * as path from "path"; +import * as fs from "fs"; import * as shell from "shelljs"; import * as _ from "lodash"; import * as constants from "../constants"; @@ -166,7 +167,7 @@ export class IOSProjectService const requestedPlatform = this.$mobileHelper.normalizePlatformName( this.$options.platformOverride ?? this.$devicePlatformsConstants.iOS, ); - // Mac Catalyst ships no runtime of its own, it reuses the iOS one. + // Hooks and plugins switch on the iOS name, Catalyst must keep it. const runtimePlatform = this.$mobileHelper.isCatalystPlatform( requestedPlatform, ) @@ -182,8 +183,8 @@ export class IOSProjectService this._platformData = { frameworkPackageName: runtimePackage.name, - normalizedPlatformName: requestedPlatform, - platformNameLowerCase: requestedPlatform.toLowerCase(), + normalizedPlatformName: runtimePlatform, + platformNameLowerCase: runtimePlatform.toLowerCase(), appDestinationDirectoryPath: path.join( projectRoot, projectData.projectName, @@ -427,6 +428,57 @@ export class IOSProjectService if (this.$fs.exists(xcframeworksFilePath)) { await this.$fs.unzip(xcframeworksFilePath, internalDirPath); this.$fs.deleteFile(xcframeworksFilePath); + this.restoreVersionedFrameworkSymlinks(internalDirPath); + } + } + + /** + * Recreates the symlink layout of versioned frameworks after extraction. + */ + private restoreVersionedFrameworkSymlinks(rootPath: string): void { + // Runtimes zip macOS style frameworks flattened, which breaks codesign. + const frameworks = fastGlob.sync("**/*.framework", { + cwd: rootPath, + onlyDirectories: true, + absolute: true, + deep: 4, + }); + + for (const frameworkPath of frameworks) { + const versionsPath = path.join(frameworkPath, "Versions"); + if (!this.$fs.exists(versionsPath)) { + continue; + } + + const versions = this.$fs + .readDirectory(versionsPath) + .filter((name) => name !== "Current"); + if (!versions.length) { + continue; + } + + const version = versions.includes("A") ? "A" : versions[0]; + const currentPath = path.join(versionsPath, "Current"); + if (!fs.lstatSync(currentPath, { throwIfNoEntry: false })?.isSymbolicLink()) { + shell.rm("-rf", currentPath); + fs.symlinkSync(version, currentPath); + } + + for (const name of this.$fs.readDirectory( + path.join(versionsPath, version), + )) { + const topLevelPath = path.join(frameworkPath, name); + if ( + fs + .lstatSync(topLevelPath, { throwIfNoEntry: false }) + ?.isSymbolicLink() + ) { + continue; + } + + shell.rm("-rf", topLevelPath); + fs.symlinkSync(path.join("Versions", "Current", name), topLevelPath); + } } } diff --git a/lib/services/ios/xcodebuild-args-service.ts b/lib/services/ios/xcodebuild-args-service.ts index 1860772902..3bc73da2c2 100644 --- a/lib/services/ios/xcodebuild-args-service.ts +++ b/lib/services/ios/xcodebuild-args-service.ts @@ -47,6 +47,11 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { buildConfig.release ? Configurations.Release : Configurations.Debug, "-allowProvisioningUpdates", "SUPPORTS_MACCATALYST=YES", + // Sandbox needs a provisioning profile, debug runs have none. + ...(buildConfig.release ? [] : ["ENABLE_APP_SANDBOX=NO"]), + // Metadata generation needs UIKit, which lives under iOSSupport. + "OTHER_CFLAGS=$(inherited) -iframework " + + "$(SDKROOT)/System/iOSSupport/System/Library/Frameworks", // 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),