From 8c15aa1fecf094e9a425f88af36c66a854245964 Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 17:23:45 +0100 Subject: [PATCH 1/2] feat(ios): share DerivedData with Xcode and speed up incremental builds DerivedData moves into platforms/ios/build through the per-user WorkspaceSettings, so CLI and Xcode builds reuse each other's output. Settings that made the two builds differ come off the xcodebuild command line: -sdk and forced explicit modules are gone, and the deployment target moves into the Podfile post_install. Simulator builds target the specific destination, debug device builds use a plain build instead of an archive, and the compilation cache lives in platforms/.cache/ios. Device discovery stops waiting as soon as the requested device is found. --- lib/common/definitions/mobile.d.ts | 5 + .../ios/device/ios-device-operations.ts | 16 +- .../mobile/mobile-core/devices-service.ts | 51 +++-- .../test/unit-tests/mobile/devices-service.ts | 33 +++ lib/definitions/ios.d.ts | 3 + lib/definitions/project.d.ts | 6 + lib/services/cocoapods-service.ts | 206 ++++++++++++------ lib/services/ios-project-service.ts | 157 ++++++++++++- lib/services/ios/spm-service.ts | 17 +- lib/services/ios/xcodebuild-args-service.ts | 138 ++++++------ lib/services/ios/xcodebuild-service.ts | 85 ++++++-- test/cocoapods-service.ts | 182 ++++++++++++---- test/ios-project-service.ts | 133 ++++++++++- test/services/ios/xcodebuild-args-service.ts | 140 ++++++++++-- test/services/ios/xcodebuild-service.ts | 71 +++++- 15 files changed, 972 insertions(+), 271 deletions(-) diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts index bb522079fa..4c27929b2b 100644 --- a/lib/common/definitions/mobile.d.ts +++ b/lib/common/definitions/mobile.d.ts @@ -1174,6 +1174,11 @@ declare global { shouldReturnImmediateResult: boolean; platform: string; fullDiscovery?: boolean; + /** + * Ends the wait for physical devices once the device requested on the + * command line has been found by any discovery. + */ + isRequestedDeviceFound?: () => boolean; } interface IAndroidDeviceHashService { diff --git a/lib/common/mobile/ios/device/ios-device-operations.ts b/lib/common/mobile/ios/device/ios-device-operations.ts index e0675096c7..c562b29cf8 100644 --- a/lib/common/mobile/ios/device/ios-device-operations.ts +++ b/lib/common/mobile/ios/device/ios-device-operations.ts @@ -86,21 +86,25 @@ export class IOSDeviceOperations // We need this because we need to make sure that we have devices. await new Promise((resolve, reject) => { - let iterationsCount = 0; - const maxIterationsCount = 3; + const pollInterval = 100; + const maxWait = 6000; + let waited = 0; const intervalHandle: NodeJS.Timer = setInterval(() => { - if (foundDevice && !options.fullDiscovery) { + if ( + (foundDevice && !options.fullDiscovery) || + options.isRequestedDeviceFound?.() + ) { resolve(); return clearInterval(intervalHandle as unknown as number); } - iterationsCount++; - if (iterationsCount >= maxIterationsCount) { + waited += pollInterval; + if (waited >= maxWait) { clearInterval(intervalHandle as unknown as number); return resolve(); } - }, 2000); + }, pollInterval); }); } } diff --git a/lib/common/mobile/mobile-core/devices-service.ts b/lib/common/mobile/mobile-core/devices-service.ts index 15bf11787f..ed6a65950e 100644 --- a/lib/common/mobile/mobile-core/devices-service.ts +++ b/lib/common/mobile/mobile-core/devices-service.ts @@ -415,13 +415,23 @@ export class DevicesService deviceInitOpts?: Mobile.IDeviceLookingOptions, ): Promise { const options = this.getDeviceLookingOptions(deviceInitOpts); - - for (const deviceDiscovery of this._allDeviceDiscoveries) { + const startLooking = async (deviceDiscovery: Mobile.IDeviceDiscovery) => { try { await deviceDiscovery.startLookingForDevices(options); } catch (err) { this.$logger.trace("Error while checking for devices.", err); } + }; + + // Concurrently, so a requested simulator ends the wait for physical + // devices. Otherwise in order, as device indexes follow discovery order. + if (options.isRequestedDeviceFound) { + await Promise.all(this._allDeviceDiscoveries.map(startLooking)); + return; + } + + for (const deviceDiscovery of this._allDeviceDiscoveries) { + await startLooking(deviceDiscovery); } } @@ -516,6 +526,18 @@ export class DevicesService * The identifier is expected to be the same as the running device declares it (emulator-5554 for android or GUID for ios). * @param identifier running emulator or device identifier */ + private findDevice(deviceOption: string): Mobile.IDevice { + return _.find( + this.getDeviceInstances(), + (d) => + (d.deviceInfo.identifier && d.deviceInfo.identifier === deviceOption) || + (d.deviceInfo.displayName && + d.deviceInfo.displayName === deviceOption) || + (d.deviceInfo.imageIdentifier && + d.deviceInfo.imageIdentifier === deviceOption), + ); + } + public getDeviceByIdentifier(identifier: string): Mobile.IDevice { const searchedDevice = _.find( this.getDeviceInstances(), @@ -581,20 +603,7 @@ export class DevicesService * @param deviceOption parameter passed by the user to --device flag. Can be name, identifier or imageIdentifier. */ public async getDevice(deviceOption: string): Promise { - let device: Mobile.IDevice = null; - - if (!device) { - device = _.find( - this.getDeviceInstances(), - (d) => - (d.deviceInfo.identifier && - d.deviceInfo.identifier === deviceOption) || - (d.deviceInfo.displayName && - d.deviceInfo.displayName === deviceOption) || - (d.deviceInfo.imageIdentifier && - d.deviceInfo.imageIdentifier === deviceOption), - ); - } + let device = this.findDevice(deviceOption); if (!device && helpers.isNumberWithoutExponent(deviceOption)) { device = this.getDeviceByIndex(parseInt(deviceOption, 10)); @@ -907,6 +916,13 @@ export class DevicesService } deviceInitOpts = deviceInitOpts || {}; + const deviceOption = deviceInitOpts.deviceId; + if (deviceOption && !helpers.isNumberWithoutExponent(deviceOption)) { + deviceInitOpts = { + ...deviceInitOpts, + isRequestedDeviceFound: () => !!this.findDevice(deviceOption), + }; + } this._data = deviceInitOpts; if (!deviceInitOpts.skipEmulatorStart) { @@ -915,13 +931,13 @@ export class DevicesService } const platform = deviceInitOpts.platform; - const deviceOption = deviceInitOpts.deviceId; const deviceLookingOptions: Mobile.IDeviceLookingOptions = { emulator: deviceInitOpts.emulator, platform: deviceInitOpts.platform, shouldReturnImmediateResult: deviceInitOpts.shouldReturnImmediateResult, detectionInterval: deviceInitOpts.detectionInterval, fullDiscovery: deviceInitOpts.fullDiscovery, + isRequestedDeviceFound: deviceInitOpts.isRequestedDeviceFound, }; if (platform && deviceOption) { @@ -1198,6 +1214,7 @@ export class DevicesService shouldReturnImmediateResult: !!shouldReturnImmediateResult, emulator: !!emulator, fullDiscovery: deviceInitOpts.fullDiscovery, + isRequestedDeviceFound: deviceInitOpts.isRequestedDeviceFound, }; } diff --git a/lib/common/test/unit-tests/mobile/devices-service.ts b/lib/common/test/unit-tests/mobile/devices-service.ts index ae319ce0b9..0f70915973 100644 --- a/lib/common/test/unit-tests/mobile/devices-service.ts +++ b/lib/common/test/unit-tests/mobile/devices-service.ts @@ -1237,6 +1237,39 @@ describe("devicesService", () => { ); }); + it("stops waiting for physical devices once the requested simulator is found", async () => { + let waitedForRequestedDevice = false; + (iOSDeviceDiscovery).startLookingForDevices = ( + options: Mobile.IDeviceLookingOptions, + ) => + new Promise((resolve) => { + const poll = setInterval(() => { + if (options.isRequestedDeviceFound?.()) { + waitedForRequestedDevice = true; + clearInterval(poll); + resolve(); + } + }, 5); + }); + (iOSSimulatorDiscovery).startLookingForDevices = async () => { + iOSSimulatorDiscovery.emit( + DeviceDiscoveryEventNames.DEVICE_FOUND, + iOSSimulator, + ); + }; + + await devicesService.initialize({ + platform: "ios", + deviceId: iOSSimulator.deviceInfo.identifier, + skipEmulatorStart: true, + }); + + assert.isTrue(waitedForRequestedDevice); + assert.deepStrictEqual(devicesService.getDeviceInstances(), [ + iOSSimulator, + ]); + }); + describe("when only deviceIdentifier is passed", () => { const assertAllMethodsResults = async (deviceId: string) => { assert.isFalse( diff --git a/lib/definitions/ios.d.ts b/lib/definitions/ios.d.ts index 60c3430bbb..08c67db5c7 100644 --- a/lib/definitions/ios.d.ts +++ b/lib/definitions/ios.d.ts @@ -114,6 +114,9 @@ declare global { platformData: IPlatformData, projectData: IProjectData, ): string[]; + shouldArchiveDeviceBuild( + buildConfig: IBuildConfig & { buildForAppStore?: boolean }, + ): boolean; } interface IXcodebuildCommandService { diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index cd2a931617..53c0d2d10d 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -563,6 +563,12 @@ interface ICocoaPodsService { platformData: IPlatformData, ): Promise; + applyPodfileBuildSettings( + projectData: IProjectData, + platformData: IPlatformData, + buildSettings: Record | null, + ): Promise; + applyPodfileFromExtensions( projectData: IProjectData, platformData: IPlatformData, diff --git a/lib/services/cocoapods-service.ts b/lib/services/cocoapods-service.ts index 17e433feae..b2b132dfa3 100644 --- a/lib/services/cocoapods-service.ts +++ b/lib/services/cocoapods-service.ts @@ -35,11 +35,11 @@ export class CocoaPodsService implements ICocoaPodsService { private $logger: ILogger, private $config: IConfiguration, private $xcconfigService: IXcconfigService, - private $xcodeSelectService: XcodeSelectService + private $xcodeSelectService: XcodeSelectService, ) { this.getCocoaPodsFromPodfile = _.memoize( this._getCocoaPodsFromPodfile, - getHash + getHash, ); } @@ -57,7 +57,7 @@ export class CocoaPodsService implements ICocoaPodsService { public async executePodInstall( projectRoot: string, - xcodeProjPath: string + xcodeProjPath: string, ): Promise { this.$logger.info("Installing pods..."); let podTool = this.$config.USE_POD_SANDBOX ? "sandbox-pod" : "pod"; @@ -79,7 +79,7 @@ export class CocoaPodsService implements ICocoaPodsService { if (!res.includes("Bad CPU type in executable")) { this.$logger.trace( - "Running on arm64 but pod is installed under rosetta2 - running pod through rosetta2" + "Running on arm64 but pod is installed under rosetta2 - running pod through rosetta2", ); args.unshift(podTool); args.unshift("-x86_64"); @@ -92,7 +92,7 @@ export class CocoaPodsService implements ICocoaPodsService { args, "close", { cwd: projectRoot, stdio: ["pipe", process.stdout, process.stdout] }, - { throwError: false } + { throwError: false }, ); if (podInstallResult.exitCode !== 0) { @@ -113,28 +113,28 @@ ${versionResolutionHint}`); public async mergePodXcconfigFile( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const podFilesRootDirName = path.join( "Pods", "Target Support Files", - `Pods-${projectData.projectName}` + `Pods-${projectData.projectName}`, ); const podFolder = path.join(platformData.projectRoot, podFilesRootDirName); if (this.$fs.exists(podFolder)) { const pluginsXcconfigFilePaths = this.$xcconfigService.getPluginsXcconfigFilePaths( - platformData.projectRoot + platformData.projectRoot, ); for (const configuration in pluginsXcconfigFilePaths) { const pluginsXcconfigFilePath = pluginsXcconfigFilePaths[configuration]; const podXcconfigFilePath = path.join( podFolder, - `Pods-${projectData.projectName}.${configuration}.xcconfig` + `Pods-${projectData.projectName}.${configuration}.xcconfig`, ); await this.$xcconfigService.mergeFiles( podXcconfigFilePath, - pluginsXcconfigFilePath + pluginsXcconfigFilePath, ); } } @@ -142,13 +142,13 @@ ${versionResolutionHint}`); public async applyPodfileFromAppResources( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const { projectRoot, normalizedPlatformName } = platformData; const mainPodfilePath = path.join( projectData.appResourcesDirectoryPath, normalizedPlatformName, - PODFILE_NAME + PODFILE_NAME, ); const projectPodfilePath = this.getProjectPodfilePath(projectRoot); if ( @@ -159,14 +159,14 @@ ${versionResolutionHint}`); NS_BASE_PODFILE, mainPodfilePath, projectData, - platformData + platformData, ); } } public async applyPodfileArchExclusions( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const xcodeVersionData = await this.$xcodeSelectService.getXcodeVersion(); @@ -194,24 +194,90 @@ end`.trim(); "NativeScript-CLI-Architecture-Exclusions", exclusionsPodfile, projectData, - platformData + platformData, ); // clean up this.$fs.deleteFile(exclusionsPodfile); } + /** + * Applies the app's build settings that pods need too: raises pods below + * the app's deployment target (from build.xcconfig) up to it, and sets the + * given settings on every pod target. Living in the Pods project rather + * than on the xcodebuild command line, they apply the same way to CLI and + * Xcode builds. + */ + public async applyPodfileBuildSettings( + projectData: IProjectData, + platformData: IPlatformData, + buildSettings: Record | null, + ): Promise { + const { projectRoot } = platformData; + const buildSettingsPodfile = path.join( + projectRoot, + "Podfile-build-settings", + ); + const deploymentTarget = this.$xcconfigService.readPropertyValue( + path.join( + projectData.appResourcesDirectoryPath, + platformData.normalizedPlatformName, + constants.BUILD_XCCONFIG_FILE_NAME, + ), + "IPHONEOS_DEPLOYMENT_TARGET", + ); + + const configLines = _.map( + buildSettings, + (value, key) => + ` config.build_settings[${JSON.stringify(key)}] = ${JSON.stringify(value)}`, + ); + if (deploymentTarget) { + configLines.push( + ` current = config.build_settings["IPHONEOS_DEPLOYMENT_TARGET"]`, + ` if current.nil? || Gem::Version.new(current) < Gem::Version.new(${JSON.stringify(deploymentTarget)})`, + ` config.build_settings["IPHONEOS_DEPLOYMENT_TARGET"] = ${JSON.stringify(deploymentTarget)}`, + ` end`, + ); + } + + if (configLines.length) { + this.$fs.writeFile( + buildSettingsPodfile, + [ + "post_install do |installer|", + " installer.pods_project.targets.each do |target|", + " target.build_configurations.each do |config|", + ...configLines, + " end", + " end", + "end", + ].join(EOL), + ); + } + + // Without a temporary Podfile this removes a previously applied section. + await this.applyPodfileToProject( + "NativeScript-CLI-Build-Settings", + buildSettingsPodfile, + projectData, + platformData, + ); + + this.$fs.deleteFile(buildSettingsPodfile); + } + public async applyPodfileFromExtensions( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ) { const extensionFolderPath = path.join( projectData.getAppResourcesDirectoryPath(), constants.iOSAppResourcesFolderName, - constants.NATIVE_EXTENSION_FOLDER + constants.NATIVE_EXTENSION_FOLDER, ); const projectPodfilePath = this.getProjectPodfilePath( - platformData.projectRoot + platformData.projectRoot, ); if ( @@ -235,7 +301,7 @@ end`.trim(); podfilePath: path.join( extensionFolderPath, name, - constants.PODFILE_NAME + constants.PODFILE_NAME, ), })); @@ -244,9 +310,9 @@ end`.trim(); const regExpToRemove = new RegExp( `${this.getExtensionPodfileHeader( podfilePath, - targetName + targetName, )}[\\s\\S]*?${this.getExtensionPodfileEnd()}`, - "mg" + "mg", ); projectPodFileContent = projectPodFileContent.replace(regExpToRemove, ""); @@ -270,7 +336,7 @@ end`.trim(); moduleName: string, podfilePath: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): Promise { const nativeProjectPath = platformData.projectRoot; if (!this.$fs.exists(podfilePath)) { @@ -278,7 +344,7 @@ end`.trim(); moduleName, podfilePath, projectData, - nativeProjectPath + nativeProjectPath, ); return; } @@ -288,7 +354,7 @@ end`.trim(); podfilePath, moduleName, projectData, - platformData + platformData, ); const pathToProjectPodfile = this.getProjectPodfilePath(nativeProjectPath); const projectPodfileContent = this.$fs.exists(pathToProjectPodfile) @@ -301,24 +367,24 @@ end`.trim(); moduleName, podfilePath, projectData, - nativeProjectPath + nativeProjectPath, ); let finalPodfileContent = this.$fs.exists(pathToProjectPodfile) ? this.getPodfileContentWithoutTarget( projectData, - this.$fs.readText(pathToProjectPodfile) - ) + this.$fs.readText(pathToProjectPodfile), + ) : ""; if ( podfileContent.indexOf( - CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME + CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME, ) !== -1 ) { finalPodfileContent = this.addPostInstallHook( replacedFunctions, finalPodfileContent, - podfileContent + podfileContent, ); } @@ -326,7 +392,7 @@ end`.trim(); finalPodfileContent = this.$cocoaPodsPlatformManager.addPlatformSection( projectData, podfilePlatformData, - finalPodfileContent + finalPodfileContent, ); } @@ -334,7 +400,7 @@ end`.trim(); this.saveProjectPodfile( projectData, finalPodfileContent, - nativeProjectPath + nativeProjectPath, ); } } @@ -343,33 +409,33 @@ end`.trim(); moduleName: string, podfilePath: string, projectData: IProjectData, - projectRoot: string + projectRoot: string, ): void { if (this.$fs.exists(this.getProjectPodfilePath(projectRoot))) { let projectPodFileContent = this.$fs.readText( - this.getProjectPodfilePath(projectRoot) + this.getProjectPodfilePath(projectRoot), ); // Remove the data between #Begin Podfile and #EndPodfile const regExpToRemove = new RegExp( `${this.getPluginPodfileHeader( - podfilePath + podfilePath, )}[\\s\\S]*?${this.getPluginPodfileEnd()}`, - "mg" + "mg", ); projectPodFileContent = projectPodFileContent.replace(regExpToRemove, ""); projectPodFileContent = this.removePostInstallHook( moduleName, - projectPodFileContent + projectPodFileContent, ); projectPodFileContent = this.$cocoaPodsPlatformManager.removePlatformSection( moduleName, projectPodFileContent, - podfilePath + podfilePath, ); const defaultPodfileBeginning = this.getPodfileHeader( - projectData.projectName + projectData.projectName, ); const defaultContentWithPostInstallHook = `${defaultPodfileBeginning}${this.getPostInstallHookHeader()}end${EOL}end`; const defaultContentWithoutPostInstallHook = `${defaultPodfileBeginning}${EOL}end`; @@ -383,7 +449,7 @@ end`.trim(); } else { this.$fs.writeFile( this.getProjectPodfilePath(projectRoot), - projectPodFileContent + projectPodFileContent, ); } } @@ -391,11 +457,11 @@ end`.trim(); public getPluginPodfilePath(pluginData: IPluginData): string { const pluginPlatformsFolderPath = pluginData.pluginPlatformsFolderPath( - PlatformTypes.ios + PlatformTypes.ios, ); const pluginPodFilePath = path.join( pluginPlatformsFolderPath, - PODFILE_NAME + PODFILE_NAME, ); return pluginPodFilePath; } @@ -403,7 +469,7 @@ end`.trim(); private addPostInstallHook( replacedFunctions: IRubyFunction[], finalPodfileContent: string, - pluginPodfileContent: string + pluginPodfileContent: string, ): string { const postInstallHookStart = this.getPostInstallHookHeader(); let postInstallHookContent = ""; @@ -424,11 +490,11 @@ end`.trim(); if (index !== -1) { const regExp = new RegExp( `(${regExpEscape(postInstallHookStart)}[\\s\\S]*?)(\\bend\\b)`, - "m" + "m", ); finalPodfileContent = finalPodfileContent.replace( regExp, - `$1${postInstallHookContent.trimRight()}${EOL}$2` + `$1${postInstallHookContent.trimRight()}${EOL}$2`, ); } else { if (finalPodfileContent.length > 0) { @@ -444,13 +510,13 @@ end`.trim(); private getPodfileContentWithoutTarget( projectData: IProjectData, - projectPodfileContent: string + projectPodfileContent: string, ): string { const podFileHeader = this.getPodfileHeader(projectData.projectName); if (_.startsWith(projectPodfileContent, podFileHeader)) { projectPodfileContent = projectPodfileContent.substr( - podFileHeader.length + podFileHeader.length, ); const podFileFooter = this.getPodfileFooter(); @@ -458,7 +524,7 @@ end`.trim(); if (_.endsWith(projectPodfileContent, podFileFooter)) { projectPodfileContent = projectPodfileContent.substr( 0, - projectPodfileContent.length - podFileFooter.length + projectPodfileContent.length - podFileFooter.length, ); } } @@ -469,11 +535,11 @@ end`.trim(); private saveProjectPodfile( projectData: IProjectData, projectPodfileContent: string, - projectRoot: string + projectRoot: string, ): void { projectPodfileContent = this.getPodfileContentWithoutTarget( projectData, - projectPodfileContent + projectPodfileContent, ); const podFileHeader = this.getPodfileHeader(projectData.projectName); const podFileFooter = this.getPodfileFooter(); @@ -484,14 +550,14 @@ end`.trim(); private removePostInstallHook( moduleName: string, - projectPodFileContent: string + projectPodFileContent: string, ): string { const regExp = new RegExp( `^.*?${this.getHookBasicFuncNameForPlugin( CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME, - moduleName + moduleName, )}.*?$\\r?\\n`, - "gm" + "gm", ); projectPodFileContent = projectPodFileContent.replace(regExp, ""); return projectPodFileContent; @@ -499,7 +565,7 @@ end`.trim(); private getHookBasicFuncNameForPlugin( hookName: string, - pluginName: string + pluginName: string, ): string { // nativescript-hook and nativescript_hook should have different names, so replace all _ with ___ first and then replace all special symbols with _ // This will lead to a clash in case plugins are called nativescript-hook and nativescript___hook @@ -512,13 +578,13 @@ end`.trim(); private replaceHookContent( hookName: string, podfileContent: string, - pluginName: string + pluginName: string, ): { replacedContent: string; newFunctions: IRubyFunction[] } { const hookStart = `${hookName} do`; const hookDefinitionRegExp = new RegExp( `${hookStart} *(\\|(\\w+)\\|)?`, - "g" + "g", ); const newFunctions: IRubyFunction[] = []; @@ -528,11 +594,11 @@ end`.trim(); substring: string, firstGroup: string, secondGroup: string, - index: number + index: number, ): string => { const newFunctionName = `${this.getHookBasicFuncNameForPlugin( hookName, - pluginName + pluginName, )}_${newFunctions.length}`; let newDefinition = `def ${newFunctionName}`; @@ -545,7 +611,7 @@ end`.trim(); newFunctions.push(rubyFunction); return newDefinition; - } + }, ); return { replacedContent, newFunctions }; @@ -563,11 +629,11 @@ end`.trim(); private getExtensionPodfileHeader( extensionPodFilePath: string, - targetName: string + targetName: string, ): string { const targetHeader = `target "${targetName.trim()}" do`; return `${this.getPluginPodfileHeader( - extensionPodFilePath + extensionPodFilePath, )}${EOL}${targetHeader}`; } @@ -583,7 +649,7 @@ end`.trim(); pluginPodFilePath: string, pluginName: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): { podfileContent: string; replacedFunctions: IRubyFunction[]; @@ -593,11 +659,11 @@ end`.trim(); const data = this.replaceHookContent( CocoaPodsService.PODFILE_POST_INSTALL_SECTION_NAME, pluginPodfileContent, - pluginName + pluginName, ); const cocoapodsData = this.$cocoaPodsPlatformManager.replacePlatformRow( data.replacedContent, - pluginPodFilePath + pluginPodFilePath, ); const podfilePlatformData = cocoapodsData.podfilePlatformData; let replacedContent = cocoapodsData.replacedContent; @@ -610,13 +676,13 @@ end`.trim(); replacedContent = this.overridePodsFromFile( replacedContent, projectData, - platformData + platformData, ); } return { podfileContent: `${this.getPluginPodfileHeader( - pluginPodFilePath + pluginPodFilePath, )}${EOL}${replacedContent}${EOL}${this.getPluginPodfileEnd()}`, replacedFunctions: data.newFunctions, podfilePlatformData, @@ -625,19 +691,19 @@ end`.trim(); private getMainPodFilePath( projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): string { return path.join( projectData.appResourcesDirectoryPath, platformData.normalizedPlatformName, - PODFILE_NAME + PODFILE_NAME, ); } private isMainPodFile( podFilePath: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): boolean { const mainPodfilePath = this.getMainPodFilePath(projectData, platformData); @@ -647,7 +713,7 @@ end`.trim(); private overridePodsFromFile( podfileContent: string, projectData: IProjectData, - platformData: IPlatformData + platformData: IPlatformData, ): string { const mainPodfilePath = this.getMainPodFilePath(projectData, platformData); @@ -657,7 +723,7 @@ end`.trim(); _.forEach(pods, (pod) => { podfileContent = podfileContent.replace( new RegExp(`^[ ]*pod\\s*["']${pod}['"].*$`, "gm"), - "#$&" + "#$&", ); }); } diff --git a/lib/services/ios-project-service.ts b/lib/services/ios-project-service.ts index 7ac6f30c0e..c7cc428353 100644 --- a/lib/services/ios-project-service.ts +++ b/lib/services/ios-project-service.ts @@ -7,7 +7,7 @@ import * as helpers from "../common/helpers"; import { attachAwaitDetach } from "../common/helpers"; import * as projectServiceBaseLib from "./platform-project-service-base"; import { PlistSession, Reporter } from "../tools/plist-merge/plist-session"; -import { EOL } from "os"; +import { EOL, userInfo } from "os"; import * as plist from "plist"; import * as fastGlob from "fast-glob"; import { IOSProvisionService } from "./ios-provision-service"; @@ -70,6 +70,31 @@ export const SimulatorPlatformSdkName = "iphonesimulator"; export const VisionDevicePlatformSdkName = "xros"; export const VisionSimulatorPlatformSdkName = "xrsimulator"; +const DERIVED_DATA_RELATIVE_PATH = path.join( + constants.BUILD_DIR, + "DerivedData", +); + +/** + * With a workspace-relative DerivedData location, Xcode appends the workspace + * name to it, so this matches what Xcode uses and lets CLI and Xcode builds + * share one cache. + */ +export const getDerivedDataPath = ( + projectRoot: string, + projectName: string, +): string => path.join(projectRoot, DERIVED_DATA_RELATIVE_PATH, projectName); + +/** + * Lives next to the platform folder rather than in it, so regenerating + * platforms/ios still reuses unchanged compiles; deleting platforms/ clears it. + */ +export const getCompilationCachePath = ( + platformsDir: string, + platformName: string, +): string => + path.join(platformsDir, ".cache", platformName, "CompilationCache"); + const FRAMEWORK_EXTENSIONS = [".framework", ".xcframework"]; const getPlatformSdkName = (buildData: IBuildData): string => { @@ -177,8 +202,9 @@ export class IOSProjectService getBuildOutputPath: (options: IBuildData): string => { const config = getConfigurationName(!options || options.release); return path.join( - projectRoot, - constants.BUILD_DIR, + getDerivedDataPath(projectRoot, projectData.projectName), + "Build", + "Products", `${config}-${getPlatformSdkName(options)}`, ); }, @@ -437,6 +463,7 @@ export class IOSProjectService buildData: IOSBuildData, ): Promise { const platformData = this.getPlatformData(projectData); + this.ensureWorkspaceDerivedDataLocation(projectData, platformData); // On a first build, the runtime (and any other Swift packages) download // here. Pre-resolve under a clear spinner so the subsequent @@ -928,6 +955,8 @@ export class IOSProjectService "The support for Apple Watch App is currently in Beta. For more information about the current development state and any known issues, please check the relevant GitHub issue: https://github.com/NativeScript/nativescript-cli/issues/4589", ); } + + this.ensureWorkspaceDerivedDataLocation(projectData, platformData); } public prepareAppResources(projectData: IProjectData): void { @@ -1292,6 +1321,11 @@ export class IOSProjectService projectData, platformData, ); + await this.$cocoapodsService.applyPodfileBuildSettings( + projectData, + platformData, + this.getCompilationCacheSettings(projectData), + ); await this.$cocoapodsService.applyPodfileFromExtensions( projectData, platformData, @@ -1370,6 +1404,10 @@ export class IOSProjectService } } + // `pod install` may have just created the .xcworkspace, and Swift + // packages resolve into the location this sets. + this.ensureWorkspaceDerivedDataLocation(projectData, platformData); + await this.$spmService.applySPMPackages( platformData, projectData, @@ -1377,6 +1415,73 @@ export class IOSProjectService ); } + /** + * Puts DerivedData inside the platforms folder for both xcodebuild and + * Xcode, so CLI and Xcode builds share one cache. Xcode ignores this + * setting in xcshareddata, so it has to be the per-user file. + */ + private ensureWorkspaceDerivedDataLocation( + projectData: IProjectData, + platformData: IPlatformData, + ): void { + if (this.$options.hostProjectPath) { + return; + } + + const { projectRoot } = platformData; + const xcodeprojPath = path.join( + projectRoot, + `${projectData.projectName}.xcodeproj`, + ); + const podsWorkspacePath = path.join( + projectRoot, + `${projectData.projectName}.xcworkspace`, + ); + const workspacePaths = [ + ...(this.$fs.exists(xcodeprojPath) + ? [path.join(xcodeprojPath, "project.xcworkspace")] + : []), + ...(this.$fs.exists(podsWorkspacePath) ? [podsWorkspacePath] : []), + ]; + const settings: Record = { + DerivedDataLocationStyle: "WorkspaceRelativePath", + DerivedDataCustomLocation: DERIVED_DATA_RELATIVE_PATH, + }; + + for (const workspacePath of workspacePaths) { + const contentsPath = path.join(workspacePath, "contents.xcworkspacedata"); + if (!this.$fs.exists(contentsPath)) { + this.$fs.writeFile( + contentsPath, + `${EOL}${EOL} ${EOL} ${EOL}${EOL}`, + ); + } + + const settingsPath = path.join( + workspacePath, + "xcuserdata", + `${userInfo().username}.xcuserdatad`, + "WorkspaceSettings.xcsettings", + ); + let existing: Record = {}; + if (this.$fs.exists(settingsPath)) { + try { + existing = plist.parse(this.$fs.readText(settingsPath)); + } catch (err) { + this.$logger.trace(`Unable to parse ${settingsPath}: `, err); + } + } + if (_.isMatch(existing, settings)) { + continue; + } + + this.$fs.writeFile( + settingsPath, + plist.build({ ...existing, ...settings }), + ); + } + } + public beforePrepareAllPlugins( projectData: IProjectData, dependencies?: IDependencyData[], @@ -1872,6 +1977,27 @@ export class IOSProjectService } } + const compilationCacheSettings = + this.getCompilationCacheSettings(projectData); + if (compilationCacheSettings) { + const tempDir = await this.$tempService.mkdirSync("compilation-cache"); + const tempXcconfigPath = path.join(tempDir, "compilation-cache.xcconfig"); + this.$fs.writeFile( + tempXcconfigPath, + _.map( + compilationCacheSettings, + (value, key) => `${key} = ${value}${EOL}`, + ).join(""), + ); + // Merged last, so the app's or a plugin's xcconfig can turn it off. + for (const pluginsXcconfigFilePath of pluginsXcconfigFilePaths) { + await this.$xcconfigService.mergeFiles( + tempXcconfigPath, + pluginsXcconfigFilePath, + ); + } + } + for (const pluginsXcconfigFilePath of pluginsXcconfigFilePaths) { // Set Entitlements Property to point to default file if not set explicitly by the user. const entitlementsPropertyValue = this.$xcconfigService.readPropertyValue( @@ -1909,6 +2035,31 @@ export class IOSProjectService } } + /** + * Xcode's compilation cache replays compiles whose inputs are unchanged, + * even after the project is regenerated and file timestamps change. + */ + public getCompilationCacheSettings( + projectData: IProjectData, + ): Record | null { + if (this.$options.hostProjectPath) { + return null; + } + + const platformData = this.getPlatformData(projectData); + return { + COMPILATION_CACHE_ENABLE_CACHING: + this.$xcconfigService.readPropertyValue( + this.getBuildXCConfigFilePath(projectData), + "COMPILATION_CACHE_ENABLE_CACHING", + ) || "YES", + COMPILATION_CACHE_CAS_PATH: getCompilationCachePath( + projectData.platformsDir, + platformData.platformNameLowerCase, + ), + }; + } + private getBuildXCConfigFilePath(projectData: IProjectData): string { const buildXCConfig = path.join( projectData.appResourcesDirectoryPath, diff --git a/lib/services/ios/spm-service.ts b/lib/services/ios/spm-service.ts index b4515b4dc7..d31b07c4b4 100644 --- a/lib/services/ios/spm-service.ts +++ b/lib/services/ios/spm-service.ts @@ -1,6 +1,7 @@ import { injector } from "../../common/yok"; import { IProjectConfigService, IProjectData } from "../../definitions/project"; import { IPlatformData } from "../../definitions/platform"; +import { getDerivedDataPath } from "../ios-project-service"; import { IFileSystem } from "../../common/declarations"; import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service"; import { color } from "../../color"; @@ -531,9 +532,9 @@ export class SPMService implements ISPMService { } /** - * True when a Package.resolved already exists for the project — i.e. packages - * have been resolved at least once, so the build's own resolve step will be a - * fast no-op rather than a slow, silent first-time download. + * True when a Package.resolved exists and packages were already fetched into + * the build's DerivedData, so the build's own resolve step will be a fast + * no-op rather than a slow, silent first-time download. */ private arePackagesResolved( platformData: IPlatformData, @@ -556,7 +557,15 @@ export class SPMService implements ISPMService { "Package.resolved", ), ]; - return candidates.some((p) => this.$fs.exists(p)); + return ( + candidates.some((p) => this.$fs.exists(p)) && + this.$fs.exists( + path.join( + getDerivedDataPath(platformData.projectRoot, projectData.projectName), + "SourcePackages", + ), + ) + ); } } injector.register("spmService", SPMService); diff --git a/lib/services/ios/xcodebuild-args-service.ts b/lib/services/ios/xcodebuild-args-service.ts index fbc5a2ebe4..0104caf2fe 100644 --- a/lib/services/ios/xcodebuild-args-service.ts +++ b/lib/services/ios/xcodebuild-args-service.ts @@ -7,18 +7,13 @@ import { IBuildConfig, IiOSBuildConfig, } from "../../definitions/project"; -import { IXcconfigService } from "../../declarations"; +import { IOptions, IXcconfigService } from "../../declarations"; import { IPlatformData } from "../../definitions/platform"; import { IFileSystem } from "../../common/declarations"; import { injector } from "../../common/yok"; import * as _ from "lodash"; -import { - DevicePlatformSdkName, - SimulatorPlatformSdkName, - VisionDevicePlatformSdkName, - VisionSimulatorPlatformSdkName, -} from "../ios-project-service"; +import { getDerivedDataPath } from "../ios-project-service"; export class XcodebuildArgsService implements IXcodebuildArgsService { constructor( @@ -27,6 +22,7 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { private $fs: IFileSystem, private $iOSWatchAppService: IIOSWatchAppService, private $logger: ILogger, + private $options: IOptions, private $xcconfigService: IXcconfigService, ) {} @@ -43,13 +39,11 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { args = args.concat(["CODE_SIGN_IDENTITY="]); } - let destination = "generic/platform=iOS Simulator"; - - let isvisionOS = this.$devicePlatformsConstants.isvisionOS( - buildConfig.platform, - ); + let destination = buildConfig._device + ? `platform=iOS Simulator,id=${buildConfig._device.deviceInfo.identifier}` + : "generic/platform=iOS Simulator"; - if (isvisionOS) { + if (this.$devicePlatformsConstants.isvisionOS(buildConfig.platform)) { destination = "generic/platform=visionOS Simulator"; if (buildConfig._device) { destination += `,id=${buildConfig._device.deviceInfo.identifier}`; @@ -64,15 +58,6 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { "-configuration", buildConfig.release ? Configurations.Release : Configurations.Debug, ]) - .concat( - this.getBuildCommonArgs( - platformData, - projectData, - isvisionOS - ? VisionSimulatorPlatformSdkName - : SimulatorPlatformSdkName, - ), - ) .concat(this.getBuildLoggingArgs()) .concat(this.getXcodeProjectArgs(platformData, projectData)); @@ -89,10 +74,14 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { platformData.getBuildOutputPath(buildConfig), projectData.projectName + ".xcarchive", ); - let destination = "generic/platform=iOS"; let isvisionOS = this.$devicePlatformsConstants.isvisionOS( buildConfig.platform, ); + const shouldArchive = this.shouldArchiveDeviceBuild(buildConfig); + let destination = + !shouldArchive && buildConfig._device + ? `platform=iOS,id=${buildConfig._device.deviceInfo.identifier}` + : "generic/platform=iOS"; if (isvisionOS) { destination = "generic/platform=visionOS"; @@ -103,22 +92,13 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { const args = [ "-destination", destination, - "archive", - "-archivePath", - archivePath, + ...(shouldArchive ? ["archive", "-archivePath", archivePath] : ["build"]), "-configuration", buildConfig.release ? Configurations.Release : Configurations.Debug, "-allowProvisioningUpdates", ] .concat(this.getXcodeProjectArgs(platformData, projectData)) .concat(architectures) - .concat( - this.getBuildCommonArgs( - platformData, - projectData, - isvisionOS ? VisionDevicePlatformSdkName : DevicePlatformSdkName, - ), - ) .concat(this.getBuildLoggingArgs()); // pbxproj-dom sets CODE_SIGN_IDENTITY[sdk=iphoneos*] which doesn't match @@ -134,10 +114,20 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { return args; } + /** + * Debug device builds use a plain build, like Xcode's Run, so both share + * one set of intermediates; archives are only needed for release. + */ + public shouldArchiveDeviceBuild( + buildConfig: IBuildConfig & { buildForAppStore?: boolean }, + ): boolean { + return !!(buildConfig.release || buildConfig.buildForAppStore); + } + private async getArchitecturesArgs( buildConfig: IBuildConfig, ): Promise { - const args = []; + const args: string[] = []; if (this.$devicePlatformsConstants.isvisionOS(buildConfig.platform)) { // visionOS builds (device/simulator) are arm64-only; rely on destination for arch @@ -146,10 +136,22 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { return args; } - const devicesArchitectures = buildConfig.buildForDevice - ? await this.getArchitecturesFromConnectedDevices(buildConfig) - : []; - if (!buildConfig.buildForDevice || devicesArchitectures.length > 1) { + if (!buildConfig.buildForDevice) { + // A specific simulator destination already limits the build to its + // architecture, the same way Xcode builds for it. + if (!buildConfig._device) { + args.push("ONLY_ACTIVE_ARCH=NO"); + } + return args; + } + + if (buildConfig._device && !this.shouldArchiveDeviceBuild(buildConfig)) { + return args; + } + + const devicesArchitectures = + await this.getArchitecturesFromConnectedDevices(buildConfig); + if (devicesArchitectures.length > 1) { args.push("ONLY_ACTIVE_ARCH=NO"); } @@ -180,6 +182,17 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { skipMacroValidation, ]; + // Otherwise the workspace's DerivedData setting decides the location. + // Passing -derivedDataPath as well would move the module and SDK stat + // caches, whose paths end up in every compile command, so CLI and Xcode + // builds would no longer reuse each other's output. + if (this.$options.hostProjectPath) { + extraArgs.push( + "-derivedDataPath", + getDerivedDataPath(platformData.projectRoot, projectData.projectName), + ); + } + // Selects how xcodebuild authenticates against private Swift Package // registries ("netrc" or "keychain"). It describes the machine's // credential setup rather than the build, so it is read from the @@ -206,29 +219,20 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { // references: https://medium.com/@iostechset/why-cocoapods-eats-app-icons-79fe729808d4 // https://github.com/CocoaPods/CocoaPods/issues/7003 - // Xcode 26 makes Swift "explicitly built modules" the default. A - // regression there prevents macro/compiler-plugin SPM targets from - // resolving their swift-syntax module dependencies, failing with: - // "Unable to resolve module dependency: 'SwiftSyntax'" (and SwiftParser, - // SwiftSyntaxMacros, SwiftCompilerPlugin, SwiftDiagnostics). - // Passed as a command-line build setting so it overrides ALL targets, - // including the package targets we don't control. + // Passed as a command-line build setting so it also reaches package + // targets, but only when the app sets it: any value Xcode wouldn't use + // itself stops CLI and Xcode builds from reusing each other's output. + // Early Xcode 26 releases fail on macro packages with explicit modules + // ("Unable to resolve module dependency: 'SwiftSyntax'"); setting it to + // NO in build.xcconfig works around that. // ref: https://forums.swift.org/t/xcode-26-unable-to-find-module-dependency/80516 const explicitModulesProperty = "SWIFT_ENABLE_EXPLICIT_MODULES"; - const explicitModulesValue = - this.$xcconfigService.readPropertyValue( - BUILD_SETTINGS_FILE_PATH, - explicitModulesProperty, - ) || "NO"; - extraArgs.push(`${explicitModulesProperty}=${explicitModulesValue}`); - - const deployTargetProperty = "IPHONEOS_DEPLOYMENT_TARGET"; - const deployTargetVersion = this.$xcconfigService.readPropertyValue( + const explicitModulesValue = this.$xcconfigService.readPropertyValue( BUILD_SETTINGS_FILE_PATH, - deployTargetProperty, + explicitModulesProperty, ); - if (deployTargetVersion) { - extraArgs.push(`${deployTargetProperty}=${deployTargetVersion}`); + if (explicitModulesValue) { + extraArgs.push(`${explicitModulesProperty}=${explicitModulesValue}`); } const swiftUIBootProperty = "NS_SWIFTUI_BOOT"; @@ -267,26 +271,6 @@ export class XcodebuildArgsService implements IXcodebuildArgsService { return this.$logger.getLevel() === "INFO" ? ["-quiet"] : []; } - private getBuildCommonArgs( - platformData: IPlatformData, - projectData: IProjectData, - platformSdkName: string, - ): string[] { - let args: string[] = []; - - if (!this.$iOSWatchAppService.hasWatchApp(platformData, projectData)) { - args = args.concat(["-sdk", platformSdkName]); - } - - args = args.concat([ - "BUILD_DIR=" + path.join(platformData.projectRoot, constants.BUILD_DIR), - "SHARED_PRECOMPS_DIR=" + - path.join(platformData.projectRoot, constants.BUILD_DIR, "sharedpch"), - ]); - - return args; - } - private async getArchitecturesFromConnectedDevices( buildConfig: IiOSBuildConfig, ): Promise { diff --git a/lib/services/ios/xcodebuild-service.ts b/lib/services/ios/xcodebuild-service.ts index 4f8308dfb5..6c7d8904a9 100644 --- a/lib/services/ios/xcodebuild-service.ts +++ b/lib/services/ios/xcodebuild-service.ts @@ -2,32 +2,38 @@ import * as path from "path"; import { IPlatformData } from "../../definitions/platform"; import { IProjectData, IBuildConfig } from "../../definitions/project"; import { injector } from "../../common/yok"; +import { IFileSystem, IChildProcess } from "../../common/declarations"; export class XcodebuildService implements IXcodebuildService { constructor( + private $childProcess: IChildProcess, private $exportOptionsPlistService: IExportOptionsPlistService, + private $fs: IFileSystem, private $xcodebuildArgsService: IXcodebuildArgsService, - private $xcodebuildCommandService: IXcodebuildCommandService + private $xcodebuildCommandService: IXcodebuildCommandService, ) {} public async buildForDevice( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const args = await this.$xcodebuildArgsService.getBuildForDeviceArgs( platformData, projectData, - buildConfig + buildConfig, ); await this.$xcodebuildCommandService.executeCommand(args, { cwd: platformData.projectRoot, stdio: buildConfig && buildConfig.buildOutputStdio, }); + if (!this.$xcodebuildArgsService.shouldArchiveDeviceBuild(buildConfig)) { + return this.packageDevelopmentIpa(platformData, projectData, buildConfig); + } const archivePath = await this.createDevelopmentArchive( platformData, projectData, - buildConfig + buildConfig, ); return archivePath; } @@ -35,12 +41,12 @@ export class XcodebuildService implements IXcodebuildService { public async buildForSimulator( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const args = await this.$xcodebuildArgsService.getBuildForSimulatorArgs( platformData, projectData, - buildConfig + buildConfig, ); await this.$xcodebuildCommandService.executeCommand(args, { cwd: platformData.projectRoot, @@ -51,12 +57,12 @@ export class XcodebuildService implements IXcodebuildService { public async buildForAppStore( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const args = await this.$xcodebuildArgsService.getBuildForDeviceArgs( platformData, projectData, - buildConfig + buildConfig, ); await this.$xcodebuildCommandService.executeCommand(args, { cwd: platformData.projectRoot, @@ -65,25 +71,74 @@ export class XcodebuildService implements IXcodebuildService { const archivePath = await this.createDistributionArchive( platformData, projectData, - buildConfig + buildConfig, ); return archivePath; } + /** + * Packages the signed .app from a plain build as an .ipa, at the same path + * an exported development archive would use. + */ + private async packageDevelopmentIpa( + platformData: IPlatformData, + projectData: IProjectData, + buildConfig: IBuildConfig, + ): Promise { + const outputPath = platformData.getBuildOutputPath(buildConfig); + const appPath = path.join(outputPath, `${projectData.projectName}.app`); + const ipaPath = path.join(outputPath, `${projectData.projectName}.ipa`); + // Kept next to the .app so cp -c can clone it instead of copying. + const payloadPath = path.join(outputPath, "ipa-staging", "Payload"); + + this.$fs.deleteDirectory(path.dirname(payloadPath)); + this.$fs.deleteFile(ipaPath); + this.$fs.createDirectory(payloadPath); + try { + await this.$childProcess.spawnFromEvent( + "cp", + ["-cR", appPath, payloadPath], + "close", + {}, + { throwError: true }, + ); + await this.$childProcess.spawnFromEvent( + "ditto", + [ + "-c", + "-k", + "--norsrc", + "--zlibCompressionLevel", + "0", + "--keepParent", + payloadPath, + ipaPath, + ], + "close", + {}, + { throwError: true }, + ); + } finally { + this.$fs.deleteDirectory(path.dirname(payloadPath)); + } + + return ipaPath; + } + private async createDevelopmentArchive( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const archivePath = path.join( platformData.getBuildOutputPath(buildConfig), - projectData.projectName + ".xcarchive" + projectData.projectName + ".xcarchive", ); const output = await this.$exportOptionsPlistService.createDevelopmentExportOptionsPlist( archivePath, projectData, - buildConfig + buildConfig, ); const args = [ "-exportArchive", @@ -105,17 +160,17 @@ export class XcodebuildService implements IXcodebuildService { private async createDistributionArchive( platformData: IPlatformData, projectData: IProjectData, - buildConfig: IBuildConfig + buildConfig: IBuildConfig, ): Promise { const archivePath = path.join( platformData.getBuildOutputPath(buildConfig), - projectData.projectName + ".xcarchive" + projectData.projectName + ".xcarchive", ); const output = await this.$exportOptionsPlistService.createDistributionExportOptionsPlist( archivePath, projectData, - buildConfig + buildConfig, ); const provision = buildConfig.provision || buildConfig.mobileProvisionIdentifier; diff --git a/test/cocoapods-service.ts b/test/cocoapods-service.ts index 24281b5636..aa9431df92 100644 --- a/test/cocoapods-service.ts +++ b/test/cocoapods-service.ts @@ -6,6 +6,9 @@ import * as _ from "lodash"; import { LoggerStub, ErrorsStub } from "./stubs"; import { XcconfigService } from "../lib/services/xcconfig-service"; import * as path from "path"; +import { mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { FileSystem } from "../lib/common/file-system"; import { CocoaPodsPlatformManager } from "../lib/services/cocoapods-platform-manager"; import { IPluginData } from "../lib/definitions/plugins"; import { ICocoaPodsService, IProjectData } from "../lib/definitions/project"; @@ -27,10 +30,10 @@ interface IMergePodfileHooksTestCase { pluginData?: IPluginData; } -function createTestInjector(): IInjector { +function createTestInjector(fs: any = {}): IInjector { const testInjector: IInjector = new yok.Yok(); - testInjector.register("fs", {}); + testInjector.register("fs", fs); testInjector.register("cocoapodsService", CocoaPodsService); testInjector.register("childProcess", {}); testInjector.register("errors", ErrorsStub); @@ -61,7 +64,7 @@ function changeNewLineCharacter(input: string): string { describe("Cocoapods service", () => { if (require("os").platform() === "win32") { console.log( - "Skipping 'Cocoapods service' tests. They can work only on macOS and Linux" + "Skipping 'Cocoapods service' tests. They can work only on macOS and Linux", ); return; } @@ -92,7 +95,7 @@ describe("Cocoapods service", () => { injector: IInjector, podfileContent: string, projectPodfileContent?: string, - appResourcesPodfileContent?: string + appResourcesPodfileContent?: string, ): void => { const fs: IFileSystem = injector.resolve("fs"); @@ -567,21 +570,21 @@ end`, mockFileSystem( testInjector, testCase.input, - testCase.projectPodfileContent + testCase.projectPodfileContent, ); await cocoapodsService.applyPodfileToProject( testCase.pluginData ? testCase.pluginData.name : mockPluginData.name, cocoapodsService.getPluginPodfilePath( - testCase.pluginData || mockPluginData + testCase.pluginData || mockPluginData, ), mockProjectData, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.output) + changeNewLineCharacter(testCase.output), ); }); }); @@ -791,19 +794,19 @@ end`, mockFileSystem( testInjector, testCase.input, - testCase.projectPodfileContent + testCase.projectPodfileContent, ); cocoapodsService.removePodfileFromProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), mockProjectData, - nativeProjectPath + nativeProjectPath, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.output) + changeNewLineCharacter(testCase.output), ); }); }); @@ -818,7 +821,7 @@ end`, childProcess.exec = async ( command: string, options?: any, - execOptions?: IExecOptions + execOptions?: IExecOptions, ): Promise => { if (command === "arch -x86_64 pod --version") { // This is the command that is used to check if cocoapods is installed under Rosetta 2 @@ -835,7 +838,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => ({ stdout: "", stderr: "", @@ -857,7 +860,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { commandCalled = command; return { @@ -880,7 +883,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { commandCalled = command; assert.deepStrictEqual(args, ["install"]); @@ -908,7 +911,7 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { return { stdout: "", @@ -919,7 +922,7 @@ end`, await assert.isRejected( cocoapodsService.executePodInstall(projectRoot, xcodeProjPath), - "'pod install' command failed." + "'pod install' command failed.", ); }); @@ -935,14 +938,14 @@ end`, args: string[], event: string, options?: any, - spawnFromEventOptions?: ISpawnFromEventOptions + spawnFromEventOptions?: ISpawnFromEventOptions, ): Promise => { return expectedResult; }; const result = await cocoapodsService.executePodInstall( projectRoot, - xcodeProjPath + xcodeProjPath, ); assert.deepStrictEqual(result, expectedResult); }); @@ -1084,13 +1087,13 @@ end`, pod.name, pod.path, projectData, - platformData + platformData, ); } assert.deepStrictEqual( projectPodfileContent, - testCase.expectedProjectPodfileContentAfterApply + testCase.expectedProjectPodfileContentAfterApply, ); for (const pod of testCase.podsToRemove) { @@ -1098,13 +1101,13 @@ end`, pod.name, pod.path, projectData, - projectPodfilePath + projectPodfilePath, ); } assert.deepStrictEqual( projectPodfileContent, - testCase.expectedProjectPodfileContentAfterRemove + testCase.expectedProjectPodfileContentAfterRemove, ); }); }); @@ -1365,13 +1368,13 @@ end`, pod.name, pod.path, projectData, - platformData + platformData, ); } assert.deepStrictEqual( projectPodfileContent, - testCase.expectedProjectPodfileContent + testCase.expectedProjectPodfileContent, ); }); }); @@ -1413,27 +1416,27 @@ end`, testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.appResourcesPodContent + testCase.appResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedOutput) + changeNewLineCharacter(testCase.expectedOutput), ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedFinalOutput) + changeNewLineCharacter(testCase.expectedFinalOutput), ); }); @@ -1485,49 +1488,49 @@ end`, testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.appResourcesPodContent + testCase.appResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedOutput) + changeNewLineCharacter(testCase.expectedOutput), ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedIntermidiatOutput) + changeNewLineCharacter(testCase.expectedIntermidiatOutput), ); mockFileSystem( testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.updatedAppResourcesPodContent + testCase.updatedAppResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedFinalOutput) + changeNewLineCharacter(testCase.expectedFinalOutput), ); }); @@ -1579,50 +1582,133 @@ end`, testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.appResourcesPodContent + testCase.appResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedOutput) + changeNewLineCharacter(testCase.expectedOutput), ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedIntermidiatOutput) + changeNewLineCharacter(testCase.expectedIntermidiatOutput), ); mockFileSystem( testInjector, testCase.pluginPodContent, testCase.projectPodfileContent, - testCase.updatedAppResourcesPodContent + testCase.updatedAppResourcesPodContent, ); await cocoapodsService.applyPodfileToProject( mockPluginData.name, cocoapodsService.getPluginPodfilePath(mockPluginData), projectDataMock, - mockPlatformData + mockPlatformData, ); await cocoapodsService.applyPodfileFromAppResources( projectDataMock, - mockPlatformData + mockPlatformData, ); assert.deepStrictEqual( changeNewLineCharacter(newPodfileContent), - changeNewLineCharacter(testCase.expectedFinalOutput) + changeNewLineCharacter(testCase.expectedFinalOutput), ); }); }); }); + +describe("applyPodfileBuildSettings", () => { + const projectName = "projectName"; + + function setup(buildXcconfig: string) { + const dir = mkdtempSync(path.join(tmpdir(), "podfile-deployment-target-")); + const testInjector = createTestInjector(FileSystem); + const fs: IFileSystem = testInjector.resolve("fs"); + const cocoapodsService: ICocoaPodsService = + testInjector.resolve("cocoapodsService"); + const projectData: any = { + projectDir: dir, + projectName, + appResourcesDirectoryPath: path.join(dir, "App_Resources"), + nsConfig: { overridePods: false }, + }; + const platformData: any = { + projectRoot: path.join(dir, "platforms", "ios"), + normalizedPlatformName: "iOS", + }; + const podfilePath = path.join(platformData.projectRoot, "Podfile"); + const xcconfigPath = path.join( + projectData.appResourcesDirectoryPath, + "iOS", + "build.xcconfig", + ); + fs.writeFile( + podfilePath, + `use_frameworks!${EOL}${EOL}target "${projectName}" do${EOL}${EOL}end`, + ); + fs.writeFile(xcconfigPath, buildXcconfig); + const apply = (buildSettings: Record = null) => + cocoapodsService.applyPodfileBuildSettings( + projectData, + platformData, + buildSettings, + ); + return { fs, podfilePath, xcconfigPath, platformData, apply }; + } + + it("raises pods below the app's deployment target", async () => { + const { fs, podfilePath, platformData, apply } = setup( + "IPHONEOS_DEPLOYMENT_TARGET = 18.0;", + ); + + await apply(); + + const podfile = fs.readText(podfilePath); + assert.include(podfile, "# Begin Podfile - "); + assert.include(podfile, 'Gem::Version.new("18.0")'); + assert.include(podfile, "post_install do |installer|"); + assert.isFalse( + fs.exists(path.join(platformData.projectRoot, "Podfile-build-settings")), + ); + }); + + it("sets the given build settings on every pod target", async () => { + const { fs, podfilePath, apply } = setup(""); + + await apply({ COMPILATION_CACHE_ENABLE_CACHING: "YES" }); + + const podfile = fs.readText(podfilePath); + assert.include( + podfile, + 'config.build_settings["COMPILATION_CACHE_ENABLE_CACHING"] = "YES"', + ); + assert.notInclude(podfile, "IPHONEOS_DEPLOYMENT_TARGET"); + }); + + it("removes the section once build.xcconfig no longer sets a deployment target", async () => { + const { fs, podfilePath, xcconfigPath, apply } = setup( + "IPHONEOS_DEPLOYMENT_TARGET = 18.0;", + ); + await apply(); + + fs.writeFile(xcconfigPath, ""); + await apply(); + + const podfile = fs.readText(podfilePath); + assert.notInclude(podfile, "Podfile-build-settings"); + assert.notInclude(podfile, "IPHONEOS_DEPLOYMENT_TARGET"); + }); +}); diff --git a/test/ios-project-service.ts b/test/ios-project-service.ts index e55c28f3f2..4d54f6477d 100644 --- a/test/ios-project-service.ts +++ b/test/ios-project-service.ts @@ -39,7 +39,8 @@ import { } from "./stubs"; import { xcode } from "../lib/node/xcode"; import { mkdtempSync } from "fs"; -import { tmpdir } from "os"; +import { tmpdir, userInfo } from "os"; +import * as plist from "plist"; import * as path from "path"; import { CocoaPodsPlatformManager } from "../lib/services/cocoapods-platform-manager"; import { XcodebuildService } from "../lib/services/ios/xcodebuild-service"; @@ -406,6 +407,9 @@ describeOnMacOS("Cocoapods support", () => { const projectPath = mkdtempSync(path.join(tmpdir(), `${projectName}-`)); const testInjector = createTestInjector(projectPath, projectName); + // Keeps the expected Podfile to the plugin's own section. + testInjector.resolve("iOSProjectService").getCompilationCacheSettings = + (): null => null; const fs: IFileSystem = testInjector.resolve("fs"); const packageJsonData = { @@ -516,6 +520,9 @@ describeOnMacOS("Cocoapods support", () => { const projectPath = mkdtempSync(path.join(tmpdir(), `${projectName}-`)); const testInjector = createTestInjector(projectPath, projectName); + // Keeps the expected Podfile to the plugin's own section. + testInjector.resolve("iOSProjectService").getCompilationCacheSettings = + (): null => null; const fs: IFileSystem = testInjector.resolve("fs"); const packageJsonData = { @@ -1392,7 +1399,7 @@ describeOnMacOS("Merge Project XCConfig files", () => { }); }); - it("creates empty plugins-.xcconfig in case there are no build.xcconfig in App_Resources and in plugins", async () => { + it("creates plugins-.xcconfig with only the compilation cache settings when there are no build.xcconfig in App_Resources and in plugins", async () => { await (iOSProjectService).mergeProjectXcconfigFiles(projectData); const destinationFilePaths = @@ -1403,10 +1410,49 @@ describeOnMacOS("Merge Project XCConfig files", () => { fs.exists(destinationFilePath), `Target build xcconfig ${destinationFilePath} is missing.`, ); - const content = fs.readFile(destinationFilePath).toString(); - assert.equal(content, ""); + assert.equal( + xcconfigService.readPropertyValue( + destinationFilePath, + "COMPILATION_CACHE_ENABLE_CACHING", + ), + "YES", + ); + assert.equal( + xcconfigService.readPropertyValue( + destinationFilePath, + "COMPILATION_CACHE_CAS_PATH", + ), + path.join( + projectData.platformsDir, + ".cache", + "ios", + "CompilationCache", + ), + ); }); }); + + it("lets the app's build.xcconfig turn compilation caching off", async () => { + fs.writeFile( + appResourcesXcconfigPath, + "COMPILATION_CACHE_ENABLE_CACHING = NO", + ); + + await (iOSProjectService).mergeProjectXcconfigFiles(projectData); + + _.each( + xcconfigService.getPluginsXcconfigFilePaths(projectRoot), + (destinationFilePath) => { + assert.equal( + xcconfigService.readPropertyValue( + destinationFilePath, + "COMPILATION_CACHE_ENABLE_CACHING", + ), + "NO", + ); + }, + ); + }); }); describe("handleNativeDependenciesChange", () => { @@ -1442,6 +1488,85 @@ describe("handleNativeDependenciesChange", () => { }); }); +describe("workspace DerivedData location", () => { + const projectName = "myTestProjectName"; + const settingsFile = (workspacePath: string) => + path.join( + workspacePath, + "xcuserdata", + `${userInfo().username}.xcuserdatad`, + "WorkspaceSettings.xcsettings", + ); + + function setup() { + const dir = mkdtempSync(path.join(tmpdir(), "derivedDataLocation-")); + const testInjector = createTestInjector(dir, projectName); + const iOSProjectService = testInjector.resolve("iOSProjectService"); + const projectData = testInjector.resolve("projectData"); + const platformData = iOSProjectService.getPlatformData(projectData); + const fs: IFileSystem = testInjector.resolve("fs"); + const xcodeprojPath = path.join( + platformData.projectRoot, + `${projectName}.xcodeproj`, + ); + fs.createDirectory(xcodeprojPath); + const writeSettings = () => + iOSProjectService.ensureWorkspaceDerivedDataLocation( + projectData, + platformData, + ); + return { fs, platformData, xcodeprojPath, writeSettings }; + } + + it("points the project and pods workspaces at the CLI's DerivedData folder", () => { + const { fs, platformData, xcodeprojPath, writeSettings } = setup(); + const podsWorkspacePath = path.join( + platformData.projectRoot, + `${projectName}.xcworkspace`, + ); + fs.createDirectory(podsWorkspacePath); + + writeSettings(); + + const projectWorkspacePath = path.join( + xcodeprojPath, + "project.xcworkspace", + ); + assert.isTrue( + fs.exists(path.join(projectWorkspacePath, "contents.xcworkspacedata")), + ); + for (const workspacePath of [projectWorkspacePath, podsWorkspacePath]) { + assert.deepStrictEqual( + plist.parse(fs.readText(settingsFile(workspacePath))), + { + DerivedDataLocationStyle: "WorkspaceRelativePath", + DerivedDataCustomLocation: path.join("build", "DerivedData"), + }, + ); + } + }); + + it("keeps other workspace settings", () => { + const { fs, xcodeprojPath, writeSettings } = setup(); + const file = settingsFile(path.join(xcodeprojPath, "project.xcworkspace")); + fs.writeFile( + file, + plist.build({ + DerivedDataLocationStyle: "Default", + ShowSharedSchemesAutomaticallyEnabled: true, + }), + ); + + writeSettings(); + + assert.deepStrictEqual(plist.parse(fs.readText(file)), { + DerivedDataLocationStyle: "WorkspaceRelativePath", + DerivedDataCustomLocation: path.join("build", "DerivedData"), + ShowSharedSchemesAutomaticallyEnabled: true, + }); + }); +}); + describe("SPM Packages", () => { it("should add SPM packages to the project", async () => { // todo: add tests for SPM packages diff --git a/test/services/ios/xcodebuild-args-service.ts b/test/services/ios/xcodebuild-args-service.ts index 6bffa9e9e0..e753d177a5 100644 --- a/test/services/ios/xcodebuild-args-service.ts +++ b/test/services/ios/xcodebuild-args-service.ts @@ -12,6 +12,7 @@ function createTestInjector(data: { hasProjectWorkspace: boolean; connectedDevices?: any[]; buildXcconfigContent?: string; + hostProjectPath?: string; }): IInjector { const injector = new Yok(); injector.register("devicePlatformsConstants", DevicePlatformsConstants); @@ -29,6 +30,7 @@ function createTestInjector(data: { injector.register("logger", { getLevel: () => data.logLevel, }); + injector.register("options", { hostProjectPath: data.hostProjectPath }); injector.register("xcodebuildArgsService", XcodebuildArgsService); injector.register("childProcess", {}); injector.register("xcconfigService", XcconfigService); @@ -45,20 +47,12 @@ const appResourcesDirectoryPath = "App_Resources"; const projectName = "myApp"; const buildOutputPath = path.join(projectRoot, projectName, "archive"); -function getCommonArgs() { - return [ - "BUILD_DIR=" + path.join(projectRoot, "build"), - "SHARED_PRECOMPS_DIR=" + path.join(projectRoot, "build", "sharedpch"), - ]; -} - function getXcodeProjectArgs(data?: { hasProjectWorkspace: boolean }) { const extraArgs = [ "-scheme", projectName, "-skipPackagePluginValidation", "-skipMacroValidation", - "SWIFT_ENABLE_EXPLICIT_MODULES=NO", ]; return data && data.hasProjectWorkspace ? [ @@ -112,6 +106,27 @@ describe("xcodebuildArgsService", () => { assert.notInclude(actualArgs, "SWIFT_ENABLE_EXPLICIT_MODULES=NO"); }); + it("leaves SWIFT_ENABLE_EXPLICIT_MODULES to Xcode when build.xcconfig doesn't set it", () => { + const injector = createTestInjector({ + logLevel: "INFO", + hasProjectWorkspace: false, + }); + const xcodebuildArgsService: IXcodebuildArgsService = injector.resolve( + "xcodebuildArgsService", + ); + + const actualArgs = xcodebuildArgsService.getXcodeProjectArgs( + { projectRoot, normalizedPlatformName }, + { projectName, appResourcesDirectoryPath }, + ); + + assert.isUndefined( + actualArgs.find((arg) => + arg.startsWith("SWIFT_ENABLE_EXPLICIT_MODULES="), + ), + ); + }); + it("should include DEVELOPMENT_TEAM from build.xcconfig", () => { const injector = createTestInjector({ logLevel: "INFO", @@ -168,6 +183,27 @@ describe("xcodebuildArgsService", () => { assert.notInclude(actualArgs, "-packageAuthorizationProvider"); }); + it("passes the DerivedData location only for host projects", () => { + const argsFor = (hostProjectPath?: string) => + createTestInjector({ + logLevel: "INFO", + hasProjectWorkspace: false, + hostProjectPath, + }) + .resolve("xcodebuildArgsService") + .getXcodeProjectArgs( + { projectRoot, normalizedPlatformName }, + { projectName, appResourcesDirectoryPath }, + ); + + assert.notInclude(argsFor(), "-derivedDataPath"); + const hostArgs = argsFor("path/to/host"); + assert.strictEqual( + hostArgs[hostArgs.indexOf("-derivedDataPath") + 1], + path.join(projectRoot, "build", "DerivedData", projectName), + ); + }); + it("keeps the project path as the second argument", () => { process.env.NS_PACKAGE_AUTHORIZATION_PROVIDER = "keychain"; const injector = createTestInjector({ @@ -228,10 +264,7 @@ describe("xcodebuildArgsService", () => { "build", "-configuration", configuration, - "-sdk", - platform === "visionOS" ? "xrsimulator" : "iphonesimulator", ] - .concat(getCommonArgs()) .concat(getBuildLoggingArgs(logLevel)) .concat(getXcodeProjectArgs({ hasProjectWorkspace })); @@ -242,6 +275,73 @@ describe("xcodebuildArgsService", () => { }); }); }); + describe("getBuildForSimulatorArgs with a target simulator", () => { + it("builds for that simulator only, like Xcode does", async () => { + const injector = createTestInjector({ + logLevel: "TRACE", + hasProjectWorkspace: false, + }); + const xcodebuildArgsService: IXcodebuildArgsService = injector.resolve( + "xcodebuildArgsService", + ); + + const actualArgs = await xcodebuildArgsService.getBuildForSimulatorArgs( + { projectRoot, normalizedPlatformName }, + { projectName, appResourcesDirectoryPath }, + { + buildForDevice: false, + platform: "iOS", + _device: { deviceInfo: { identifier: "SIM-UDID" } }, + }, + ); + + assert.deepStrictEqual(actualArgs.slice(0, 3), [ + "CODE_SIGN_IDENTITY=", + "-destination", + "platform=iOS Simulator,id=SIM-UDID", + ]); + assert.notInclude(actualArgs, "ONLY_ACTIVE_ARCH=NO"); + }); + }); + + describe("getBuildForDeviceArgs with a target device", () => { + it("builds for that device, like Xcode's Run", async () => { + let queriedDevices = false; + const injector = createTestInjector({ + logLevel: "TRACE", + hasProjectWorkspace: false, + }); + injector.resolve("devicesService").initialize = async () => { + queriedDevices = true; + }; + const xcodebuildArgsService: IXcodebuildArgsService = injector.resolve( + "xcodebuildArgsService", + ); + + const actualArgs = await xcodebuildArgsService.getBuildForDeviceArgs( + { + projectRoot, + normalizedPlatformName, + getBuildOutputPath: () => buildOutputPath, + }, + { projectName, appResourcesDirectoryPath }, + { + buildForDevice: true, + platform: "ios", + _device: { deviceInfo: { identifier: "DEVICE-UDID" } }, + }, + ); + + assert.deepStrictEqual(actualArgs.slice(0, 3), [ + "-destination", + "platform=iOS,id=DEVICE-UDID", + "build", + ]); + assert.notInclude(actualArgs, "ONLY_ACTIVE_ARCH=NO"); + assert.isFalse(queriedDevices); + }); + }); + describe("getBuildForDeviceArgs", () => { const testCases = [ { @@ -250,19 +350,17 @@ describe("xcodebuildArgsService", () => { { deviceInfo: { activeArchitecture: "arm64" } }, { deviceInfo: { activeArchitecture: "armv7" } }, ], - expectedArgs: ["ONLY_ACTIVE_ARCH=NO", "-sdk", "iphoneos"].concat( - getCommonArgs(), - ), + expectedArgs: ["ONLY_ACTIVE_ARCH=NO"], }, { name: "should return correct args when there is only one connected device", connectedDevices: [{ deviceInfo: { activeArchitecture: "arm64" } }], - expectedArgs: ["-sdk", "iphoneos"].concat(getCommonArgs()), + expectedArgs: [], }, { name: "should return correct args when no connected devices", connectedDevices: [], - expectedArgs: ["-sdk", "iphoneos"].concat(getCommonArgs()), + expectedArgs: [], }, ]; @@ -303,9 +401,13 @@ describe("xcodebuildArgsService", () => { const expectedArgs = [ "-destination", "generic/platform=iOS", - "archive", - "-archivePath", - path.join(buildOutputPath, `${projectName}.xcarchive`), + ...(configuration === "Release" + ? [ + "archive", + "-archivePath", + path.join(buildOutputPath, `${projectName}.xcarchive`), + ] + : ["build"]), "-configuration", configuration, "-allowProvisioningUpdates", diff --git a/test/services/ios/xcodebuild-service.ts b/test/services/ios/xcodebuild-service.ts index 611f402c77..543cfbe577 100644 --- a/test/services/ios/xcodebuild-service.ts +++ b/test/services/ios/xcodebuild-service.ts @@ -15,9 +15,21 @@ const exportOptionsPlistOutput = { }; let actualBuildArgs: string[] = []; let actualBuildOptions: IXcodebuildCommandOptions = null; +let spawnedCommands: string[][] = []; function createTestInjector(): IInjector { const injector = new Yok(); + spawnedCommands = []; + injector.register("childProcess", { + spawnFromEvent: async (command: string, args: string[]) => { + spawnedCommands.push([command, ...args]); + }, + }); + injector.register("fs", { + createDirectory: (): void => undefined, + deleteDirectory: (): void => undefined, + deleteFile: (): void => undefined, + }); injector.register("exportOptionsPlistService", { createDevelopmentExportOptionsPlist: async () => exportOptionsPlistOutput, createDistributionExportOptionsPlist: async () => exportOptionsPlistOutput, @@ -25,11 +37,13 @@ function createTestInjector(): IInjector { injector.register("xcodebuildArgsService", { getBuildForDeviceArgs: async () => [], getBuildForSimulatorArgs: async () => [], + shouldArchiveDeviceBuild: (buildConfig: any) => + !!(buildConfig.release || buildConfig.buildForAppStore), }); injector.register("xcodebuildCommandService", { executeCommand: async ( args: string[], - options: IXcodebuildCommandOptions + options: IXcodebuildCommandOptions, ) => { actualBuildArgs = args; actualBuildOptions = options; @@ -43,7 +57,48 @@ function createTestInjector(): IInjector { describe("xcodebuildService", () => { describe("buildForDevice", () => { - it("should build correctly for device", async () => { + it("packages the built .app as an .ipa for debug builds", async () => { + const injector = createTestInjector(); + const xcodebuildService = injector.resolve("xcodebuildService"); + const platformData = { + getBuildOutputPath: () => buildOutputPath, + projectRoot, + }; + const projectData = { projectName }; + + const buildResult = await xcodebuildService.buildForDevice( + platformData, + projectData, + {}, + ); + + const payloadPath = path.join(buildOutputPath, "ipa-staging", "Payload"); + assert.deepStrictEqual(spawnedCommands, [ + [ + "cp", + "-cR", + path.join(buildOutputPath, `${projectName}.app`), + payloadPath, + ], + [ + "ditto", + "-c", + "-k", + "--norsrc", + "--zlibCompressionLevel", + "0", + "--keepParent", + payloadPath, + path.join(buildOutputPath, `${projectName}.ipa`), + ], + ]); + assert.deepStrictEqual( + buildResult, + path.join(buildOutputPath, `${projectName}.ipa`), + ); + }); + + it("exports an archive for release builds", async () => { const injector = createTestInjector(); const xcodebuildService = injector.resolve("xcodebuildService"); const platformData = { @@ -55,7 +110,7 @@ describe("xcodebuildService", () => { const buildResult = await xcodebuildService.buildForDevice( platformData, projectData, - {} + { release: true }, ); const expectedBuildArgs = [ @@ -63,7 +118,7 @@ describe("xcodebuildService", () => { "-archivePath", path.join( platformData.getBuildOutputPath(), - `${projectName}.xcarchive` + `${projectName}.xcarchive`, ), "-exportPath", exportOptionsPlistOutput.exportFileDir, @@ -77,7 +132,7 @@ describe("xcodebuildService", () => { }); assert.deepStrictEqual( buildResult, - exportOptionsPlistOutput.exportFilePath + exportOptionsPlistOutput.exportFilePath, ); }); }); @@ -113,7 +168,7 @@ describe("xcodebuildService", () => { const buildResult = await xcodebuildService.buildForAppStore( platformData, projectData, - {} + {}, ); const expectedBuildArgs = [ @@ -121,7 +176,7 @@ describe("xcodebuildService", () => { "-archivePath", path.join( platformData.getBuildOutputPath(), - `${projectName}.xcarchive` + `${projectName}.xcarchive`, ), "-exportPath", exportOptionsPlistOutput.exportFileDir, @@ -133,7 +188,7 @@ describe("xcodebuildService", () => { assert.deepStrictEqual(actualBuildOptions, { cwd: projectRoot }); assert.deepStrictEqual( buildResult, - exportOptionsPlistOutput.exportFilePath + exportOptionsPlistOutput.exportFilePath, ); }); }); From de9c0d38034900138c5e4b0e86c13aaa193dfc5e Mon Sep 17 00:00:00 2001 From: Igor Randjelovic Date: Fri, 30 Oct 2026 20:24:39 +0100 Subject: [PATCH 2/2] fix(ios): don't end device discovery on the wrong device With 100 ms polling, an unrelated device ended the wait almost immediately. A requested device now only ends it by being found, and otherwise the 2 seconds the old polling allowed still apply, so device indexes stay stable. --- .../ios/device/ios-device-operations.ts | 27 +++++++++---- .../mobile/ios-device-operations.ts | 39 +++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 lib/common/test/unit-tests/mobile/ios-device-operations.ts diff --git a/lib/common/mobile/ios/device/ios-device-operations.ts b/lib/common/mobile/ios/device/ios-device-operations.ts index c562b29cf8..bffc982e23 100644 --- a/lib/common/mobile/ios/device/ios-device-operations.ts +++ b/lib/common/mobile/ios/device/ios-device-operations.ts @@ -11,6 +11,22 @@ import { } from "../../../declarations"; import { injector } from "../../../yok"; +// Other devices can still be arriving by then, and device indexes follow the +// order they were discovered in. +const MIN_DISCOVERY_WAIT_MS = 2000; + +export function isDeviceDiscoveryDone( + foundDevice: boolean, + waitedMs: number, + options?: Mobile.IDeviceLookingOptions, +): boolean { + if (options?.isRequestedDeviceFound) { + return options.isRequestedDeviceFound(); + } + + return foundDevice && !options?.fullDiscovery && waitedMs >= MIN_DISCOVERY_WAIT_MS; +} + export class IOSDeviceOperations extends EventEmitter implements IIOSDeviceOperations, IDisposable, IShouldDispose @@ -91,16 +107,11 @@ export class IOSDeviceOperations let waited = 0; const intervalHandle: NodeJS.Timer = setInterval(() => { + waited += pollInterval; if ( - (foundDevice && !options.fullDiscovery) || - options.isRequestedDeviceFound?.() + waited >= maxWait || + isDeviceDiscoveryDone(foundDevice, waited, options) ) { - resolve(); - return clearInterval(intervalHandle as unknown as number); - } - - waited += pollInterval; - if (waited >= maxWait) { clearInterval(intervalHandle as unknown as number); return resolve(); } diff --git a/lib/common/test/unit-tests/mobile/ios-device-operations.ts b/lib/common/test/unit-tests/mobile/ios-device-operations.ts new file mode 100644 index 0000000000..1e2482d92b --- /dev/null +++ b/lib/common/test/unit-tests/mobile/ios-device-operations.ts @@ -0,0 +1,39 @@ +import { assert } from "chai"; +import { isDeviceDiscoveryDone } from "../../../mobile/ios/device/ios-device-operations"; + +describe("isDeviceDiscoveryDone", () => { + const options = (extra: object = {}) => { ...extra }; + + it("ends as soon as the requested device is found", () => { + const isRequestedDeviceFound = () => true; + assert.isTrue( + isDeviceDiscoveryDone(false, 100, options({ isRequestedDeviceFound })), + ); + }); + + it("keeps waiting for the requested device when another one shows up", () => { + const isRequestedDeviceFound = () => false; + assert.isFalse( + isDeviceDiscoveryDone(true, 5000, options({ isRequestedDeviceFound })), + ); + }); + + it("gives other devices time to arrive when none was requested", () => { + assert.isFalse(isDeviceDiscoveryDone(true, 100, options())); + assert.isTrue(isDeviceDiscoveryDone(true, 2000, options())); + }); + + it("keeps waiting while no device was found", () => { + assert.isFalse(isDeviceDiscoveryDone(false, 5000, options())); + }); + + it("doesn't end early on a full discovery", () => { + assert.isFalse( + isDeviceDiscoveryDone(true, 5000, options({ fullDiscovery: true })), + ); + }); + + it("works without options", () => { + assert.isTrue(isDeviceDiscoveryDone(true, 2000)); + }); +});