From 22eadc41d17b4ca53d70bef4a151f7b11dbdcf28 Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Tue, 11 Aug 2026 15:20:27 +0200 Subject: [PATCH 1/2] 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. Replaces the `--catalyst` build flag, which this supersedes. Verified on a real app: `ns run macos` runs pod install, generates metadata for arm64-apple-ios-macabi, links and signs the .app, launches it, streams its logs, and syncs a file change into the running app. Co-Authored-By: Claude Opus 5 --- lib/bootstrap.ts | 2 + lib/commands/build.ts | 58 +++++++ lib/commands/run.ts | 57 +++++-- 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/data/build-data.ts | 2 - lib/definitions/project.d.ts | 7 +- lib/device-path-provider.ts | 15 ++ lib/options.ts | 1 - lib/project-data.ts | 9 ++ .../bundler/bundler-compiler-service.ts | 43 ++++-- lib/services/ios-project-service.ts | 29 ++-- lib/services/ios/xcodebuild-args-service.ts | 5 +- 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, 648 insertions(+), 97 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 0eb20383b9..9960fd8789 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 08efd1f747..94ba072d57 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -282,3 +282,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..64be03f6c4 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 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 abc0a42a5c..c17ecb6e00 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -144,6 +144,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; @@ -1210,6 +1217,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; @@ -1254,10 +1262,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 c8e6fec0eb..40e136236a 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 d1e835bb8f..eb99e24e0c 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -361,12 +361,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 0f49fd5c9f..e65fd40a56 100644 --- a/lib/controllers/platform-controller.ts +++ b/lib/controllers/platform-controller.ts @@ -187,10 +187,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; @@ -205,9 +205,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 bfddf396d5..cd42780834 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, @@ -311,7 +311,7 @@ export class PrepareController extends EventEmitter { ); this.watchersData[projectData.projectDir][ - platformData.platformNameLowerCase + requestedPlatform ].hasWebpackCompilerProcess = true; await this.$bundlerCompilerService.compileWithWatch( platformData, @@ -333,6 +333,7 @@ export class PrepareController extends EventEmitter { newNativeWatchStarted = await this.startNativeWatcher( platformData, projectData, + prepareData, ); } @@ -351,11 +352,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; } @@ -387,14 +390,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/data/build-data.ts b/lib/data/build-data.ts index 7b30fc7534..0159031936 100644 --- a/lib/data/build-data.ts +++ b/lib/data/build-data.ts @@ -33,7 +33,6 @@ export class IOSBuildData extends BuildData implements IiOSBuildData { public buildForAppStore: boolean; public iCloudContainerEnvironment: string; public hostProjectPath: string; - public catalyst: boolean; constructor(projectDir: string, platform: string, data: any) { super(projectDir, platform, data); @@ -44,7 +43,6 @@ export class IOSBuildData extends BuildData implements IiOSBuildData { this.buildForAppStore = data.buildForAppStore; this.iCloudContainerEnvironment = data.iCloudContainerEnvironment; this.hostProjectPath = data.hostProjectPath; - this.catalyst = data.catalyst; } } diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index 5851d578cd..9665d11807 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -141,6 +141,8 @@ interface INsConfigIOS extends INsConfigPlaform { interface INSConfigVisionOS extends INsConfigIOS {} +interface INSConfigMacOS extends INsConfigIOS {} + interface INsConfigAndroid extends INsConfigPlaform { v8Flags?: string; @@ -210,6 +212,7 @@ interface INsConfig { ios?: INsConfigIOS; android?: INsConfigAndroid; visionos?: INSConfigVisionOS; + macos?: INSConfigMacOS; ignoredNativeDependencies?: string[]; hooks?: INsConfigHooks[]; projectName?: string; @@ -659,10 +662,6 @@ interface IiOSBuildConfig * Code sign identity used for build. If not set iPhone Developer is used as a default when building for device. */ codeSignIdentity?: string; - /** - * Build a native macOS app with Mac Catalyst instead of an iOS app. - */ - catalyst?: boolean; } /** diff --git a/lib/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/options.ts b/lib/options.ts index 04003ef85c..3a60114ec2 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -99,7 +99,6 @@ export class Options { framework: { type: OptionType.String, hasSensitiveValue: false }, frameworkVersion: { type: OptionType.String, hasSensitiveValue: false }, forDevice: { type: OptionType.Boolean, hasSensitiveValue: false }, - catalyst: { type: OptionType.Boolean, hasSensitiveValue: false }, iCloudContainerEnvironment: { type: OptionType.String, hasSensitiveValue: false, diff --git a/lib/project-data.ts b/lib/project-data.ts index 0cef3a5200..d973f16e47 100644 --- a/lib/project-data.ts +++ b/lib/project-data.ts @@ -335,6 +335,7 @@ export class ProjectData implements IProjectData { ios: "", android: "", visionos: "", + macos: "", }; } @@ -342,6 +343,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) { @@ -353,6 +356,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 7ac85f3867..eaef2142d5 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(); @@ -1044,7 +1052,7 @@ export class BundlerCompilerService hash: lastHash || message.hash, fallbackFiles: [], }, - platform: platformData.platformNameLowerCase, + platform: prepareData.platform.toLowerCase(), }); } @@ -1060,8 +1068,10 @@ export class BundlerCompilerService return path.resolve(packagePath, "bin", "vite.js"); } } else if (this.isModernBundler(projectData)) { - - const webpackPluginName = this.$projectConfigService.getValue(`webpackPackageName`, WEBPACK_PLUGIN_NAME); + const webpackPluginName = this.$projectConfigService.getValue( + `webpackPackageName`, + WEBPACK_PLUGIN_NAME, + ); const packagePath = resolvePackagePath(webpackPluginName, { paths: [projectData.projectDir], }); @@ -1070,7 +1080,7 @@ export class BundlerCompilerService return path.resolve(packagePath, "dist", "bin", "index.js"); } } - throw new Error('could not find bundler executable'); + throw new Error("could not find bundler executable"); } private isModernBundler(projectData: IProjectData): boolean { @@ -1079,7 +1089,10 @@ export class BundlerCompilerService case "rspack": return true; default: - const webpackPluginName = this.$projectConfigService.getValue(`webpackPackageName`, WEBPACK_PLUGIN_NAME); + const webpackPluginName = this.$projectConfigService.getValue( + `webpackPackageName`, + WEBPACK_PLUGIN_NAME, + ); const packageJSONPath = resolvePackageJSONPath(webpackPluginName, { paths: [projectData.projectDir], }); diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index aea924f36b..bb299690e6 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -79,7 +79,10 @@ const getPlatformSdkName = (buildData: IBuildData): string => { const forDevice = !buildData || buildData.buildForDevice || buildData.buildForAppStore; - if (buildData && (<{ catalyst?: boolean }>buildData).catalyst) { + if ( + buildData && + injector.resolve("devicePlatformsConstants").ismacOS(buildData.platform) + ) { return CatalystPlatformSdkName; } @@ -160,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, @@ -192,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( @@ -460,7 +469,7 @@ export class IOSProjectService this.emit(constants.BUILD_OUTPUT_EVENT_NAME, data); }; - if (buildData.catalyst) { + 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( @@ -1496,10 +1505,7 @@ export class IOSProjectService } private validateFramework(libraryPath: string): void { - let infoPlistPath = path.join( - libraryPath, - constants.INFO_PLIST_FILE_NAME, - ); + let infoPlistPath = path.join(libraryPath, constants.INFO_PLIST_FILE_NAME); if (!this.$fs.exists(infoPlistPath)) { infoPlistPath = path.join( libraryPath, @@ -1512,7 +1518,6 @@ export class IOSProjectService libraryPath, ); } - } const plistJson = this.$plistParser.parseFileSync(infoPlistPath); diff --git a/lib/services/ios/xcodebuild-args-service.ts b/lib/services/ios/xcodebuild-args-service.ts index 446a5e71e5..1860772902 100644 --- a/lib/services/ios/xcodebuild-args-service.ts +++ b/lib/services/ios/xcodebuild-args-service.ts @@ -38,10 +38,7 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { projectData: IProjectData, buildConfig: IBuildConfig, ): string[] { - // Mac Catalyst is a variant of the iOS platform rather than a platform of - // its own: the same target is rebuilt against the macOS SDK with the - // `-macabi` triple. `SUPPORTS_MACCATALYST` has to be forced because the - // runtime template only enables the legacy `SUPPORTS_UIKITFORMAC` alias. + // Forced because the runtime template only sets the legacy UIKITFORMAC alias. return [ "-destination", "platform=macOS,variant=Mac Catalyst", 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 d389ac3606..dc8b2fce6e 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; + } let packageName: string[] = []; if (platform === constants.PlatformTypes.ios) { packageName.push(this.$projectData.nsConfig.ios?.runtimePackageName, constants.SCOPED_IOS_RUNTIME_NAME, constants.TNS_IOS_RUNTIME_NAME); From 06f303eb29c728f492349cc82a4915e939b0b5dc Mon Sep 17 00:00:00 2001 From: Martin Guillon Date: Tue, 11 Aug 2026 17:55:16 +0200 Subject: [PATCH 2/2] 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. The renamed code is byte-identical to the upstream PR so the two merge without conflict. Co-Authored-By: Claude Opus 5 --- lib/bootstrap.ts | 4 ++-- lib/commands/build.ts | 8 ++++---- lib/commands/run.ts | 6 +++--- lib/common/definitions/mobile.d.ts | 6 +++--- lib/common/mobile/device-platforms-constants.ts | 6 +++--- .../mobile/mac/mac-catalyst-application-manager.ts | 2 +- lib/common/mobile/mac/mac-catalyst-device.ts | 4 ++-- .../mobile/mobile-core/ios-device-discovery.ts | 4 ++-- .../mobile/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 +++++----- lib/services/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, 48 insertions(+), 48 deletions(-) diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 9960fd8789..1b5a172d49 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 94ba072d57..50854b2c87 100644 --- a/lib/commands/build.ts +++ b/lib/commands/build.ts @@ -286,7 +286,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, @@ -315,12 +315,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, @@ -339,4 +339,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 64be03f6c4..28f7a2552f 100644 --- a/lib/commands/run.ts +++ b/lib/commands/run.ts @@ -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 c17ecb6e00..7f1f8ac229 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -1217,7 +1217,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; @@ -1262,12 +1262,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 eb99e24e0c..44aa8e7db8 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -361,14 +361,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 e65fd40a56..4a2c67eef0 100644 --- a/lib/controllers/platform-controller.ts +++ b/lib/controllers/platform-controller.ts @@ -187,7 +187,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 cd42780834..897b4e6e7c 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 9665d11807..b3eb20ecac 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -141,7 +141,7 @@ interface INsConfigIOS extends INsConfigPlaform { interface INSConfigVisionOS extends INsConfigIOS {} -interface INSConfigMacOS extends INsConfigIOS {} +interface INSConfigCatalyst extends INsConfigIOS {} interface INsConfigAndroid extends INsConfigPlaform { v8Flags?: string; @@ -212,7 +212,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 d973f16e47..0cfe0fe98d 100644 --- a/lib/project-data.ts +++ b/lib/project-data.ts @@ -335,7 +335,7 @@ export class ProjectData implements IProjectData { ios: "", android: "", visionos: "", - macos: "", + catalyst: "", }; } @@ -344,7 +344,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) { @@ -357,10 +357,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 eaef2142d5..3e15fd99c6 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 bb299690e6..beeff80566 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 dc8b2fce6e..501114cbe8 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; } let packageName: string[] = [];