lib/build.js (367 lines of code) (raw):

/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ const fs = require('node:fs'); const path = require('node:path'); const util = require('node:util'); const nopt = require('nopt'); const which = require('which'); const execa = require('execa'); const { CordovaError, events } = require('cordova-common'); const plist = require('plist'); const check_reqs = require('./check_reqs'); const projectFile = require('./projectFile'); const buildConfigProperties = [ 'codeSignIdentity', 'provisioningProfile', 'developmentTeam', 'packageType', 'buildFlag', 'iCloudContainerEnvironment', 'automaticProvisioning', 'authenticationKeyPath', 'authenticationKeyID', 'authenticationKeyIssuerID' ]; // These are regular expressions to detect if the user is changing any of the built-in xcodebuildArgs /* eslint-disable no-useless-escape */ const buildFlagMatchers = { workspace: /^\-workspace\s*(.*)/, scheme: /^\-scheme\s*(.*)/, configuration: /^\-configuration\s*(.*)/, sdk: /^\-sdk\s*(.*)/, destination: /^\-destination\s*(.*)/, archivePath: /^\-archivePath\s*(.*)/, configuration_build_dir: /^(CONFIGURATION_BUILD_DIR=.*)/, shared_precomps_dir: /^(SHARED_PRECOMPS_DIR=.*)/ }; /* eslint-enable no-useless-escape */ /** * Creates a project object (see projectFile.js/parseProjectFile) from * a project path and name * * @param {*} projectPath */ function createProjectObject (projectPath) { const locations = { root: projectPath, pbxproj: path.join(projectPath, 'App.xcodeproj', 'project.pbxproj') }; return projectFile.parse(locations); } /** * Returns a promise that resolves to the default simulator target; the logic here * matches what `cordova emulate ios` does. * * The return object has two properties: `name` (the Xcode destination name), * `identifier` (the simctl identifier), and `simIdentifier` (essentially the cordova emulate target) * * @return {Promise} */ function getDefaultSimulatorTarget () { events.emit('log', 'Select last emulator from list as default.'); return require('./listEmulatorBuildTargets').run() .then(emulators => { if (emulators.length === 0) { return Promise.reject(new CordovaError('Could not find any iOS simulators. Use Xcode to install simulator devices for testing.')); } let targetEmulator = emulators[0]; emulators.forEach(emulator => { if (emulator.name.indexOf('iPhone') === 0) { targetEmulator = emulator; } }); return targetEmulator; }); } function parseOptions (options) { options = options || {}; options.argv = nopt({ codeSignIdentity: String, developmentTeam: String, packageType: String, provisioningProfile: String, automaticProvisioning: Boolean, authenticationKeyPath: String, authenticationKeyID: String, authenticationKeyIssuerID: String, buildFlag: [String, Array], iCloudContainerEnvironment: String }, {}, options.argv, 0); if (options.debug && options.release) { throw new CordovaError('Cannot specify "debug" and "release" options together.'); } if (options.device && options.emulator) { throw new CordovaError('Cannot specify "device" and "emulator" options together.'); } buildConfigProperties.forEach(key => { options[key] = options.argv[key] || options[key]; }); if (options.buildConfig) { if (!fs.existsSync(options.buildConfig)) { throw new CordovaError(`Build config file does not exist: ${options.buildConfig}`); } events.emit('log', `Reading build config file: ${path.resolve(options.buildConfig)}`); const contents = fs.readFileSync(options.buildConfig, 'utf-8'); const buildConfig = JSON.parse(contents.replace(/^\ufeff/, '')); // Remove BOM if (buildConfig.ios) { const buildType = options.release ? 'release' : 'debug'; const config = buildConfig.ios[buildType]; if (config) { buildConfigProperties.forEach(key => { options[key] = options[key] || config[key]; }); } } } return options; } /** @returns {Promise<void>} */ module.exports.run = function (buildOpts) { try { buildOpts = parseOptions(buildOpts); } catch (e) { return Promise.reject(e); } const projectPath = this.root; let emulatorTarget = 'iOS Device'; if (buildOpts.target && buildOpts.target.match(/mac/i)) { buildOpts.catalyst = true; buildOpts.device = true; buildOpts.emulator = false; emulatorTarget = 'macOS Catalyst'; } return Promise.resolve() .then(() => { if (!buildOpts.emulator && !buildOpts.catalyst) { return require('./listDevices').run().then(devices => { if (devices.length > 0) { // we explicitly set device flag in options buildOpts.device = true; } }); } }) .then(() => { // CB-12287: Determine the device we should target when building for a simulator if (!buildOpts.device) { let newTarget = buildOpts.target || ''; if (newTarget) { // only grab the device name, not the runtime specifier newTarget = newTarget.split(',')[0]; } // a target was given to us, find the matching Xcode destination name const promise = require('./listEmulatorBuildTargets').targetForSimIdentifier(newTarget); return promise.then(theTarget => { if (!theTarget) { return getDefaultSimulatorTarget().then(defaultTarget => { emulatorTarget = defaultTarget.name; events.emit('warn', `No simulator found for "${newTarget}. Falling back to the default target.`); events.emit('log', `Building for "${emulatorTarget}" Simulator (${defaultTarget.identifier}, ${defaultTarget.simIdentifier}).`); return emulatorTarget; }); } else { emulatorTarget = theTarget.name; events.emit('log', `Building for "${emulatorTarget}" Simulator (${theTarget.identifier}, ${theTarget.simIdentifier}).`); return emulatorTarget; } }); } }) .then(() => check_reqs.run()) .then(() => { let extraConfig = ''; if (buildOpts.codeSignIdentity) { extraConfig += `CODE_SIGN_IDENTITY = ${buildOpts.codeSignIdentity}\n`; } if (buildOpts.provisioningProfile) { if (typeof buildOpts.provisioningProfile === 'string') { extraConfig += `PROVISIONING_PROFILE_SPECIFIER = ${buildOpts.provisioningProfile}\n`; } else { const keys = Object.keys(buildOpts.provisioningProfile); extraConfig += `PROVISIONING_PROFILE_SPECIFIER = ${buildOpts.provisioningProfile[keys[0]]}\n`; } } if (buildOpts.developmentTeam) { extraConfig += `DEVELOPMENT_TEAM = ${buildOpts.developmentTeam}\n`; } function writeCodeSignStyle (value) { const project = createProjectObject(projectPath); events.emit('verbose', `Set CODE_SIGN_STYLE Build Property to ${value}.`); project.xcode.updateBuildProperty('CODE_SIGN_STYLE', value); events.emit('verbose', `Set ProvisioningStyle Target Attribute to ${value}.`); project.xcode.addTargetAttribute('ProvisioningStyle', value); project.write(); } if (buildOpts.provisioningProfile) { events.emit('verbose', 'ProvisioningProfile build option set, changing project settings to Manual.'); writeCodeSignStyle('Manual'); } else if (buildOpts.automaticProvisioning) { events.emit('verbose', 'ProvisioningProfile build option NOT set, changing project settings to Automatic.'); writeCodeSignStyle('Automatic'); } return fs.promises.writeFile(path.join(projectPath, 'cordova', 'build-extras.xcconfig'), extraConfig, 'utf-8'); }).then(() => { const configuration = buildOpts.release ? 'Release' : 'Debug'; events.emit('log', `Building project: ${path.join(projectPath, 'App.xcworkspace')}`); events.emit('log', `\tConfiguration: ${configuration}`); events.emit('log', `\tPlatform: ${buildOpts.device ? 'device' : 'emulator'}`); events.emit('log', `\tTarget: ${emulatorTarget}`); const buildOutputDir = path.join(projectPath, 'build', `${configuration}-${(buildOpts.device ? 'iphoneos' : 'iphonesimulator')}`); // remove the build output folder before building fs.rmSync(buildOutputDir, { recursive: true, force: true }); const xcodebuildArgs = getXcodeBuildArgs(projectPath, configuration, emulatorTarget, buildOpts); return execa('xcodebuild', xcodebuildArgs, { cwd: projectPath, stdio: 'inherit' }); }).then(() => { if (!buildOpts.device || buildOpts.catalyst || buildOpts.noSign) { return; } const project = createProjectObject(projectPath); const bundleIdentifier = project.getPackageName(); const exportOptions = { ...buildOpts.exportOptions, compileBitcode: false, method: 'development' }; if (buildOpts.packageType) { exportOptions.method = buildOpts.packageType; } if (buildOpts.iCloudContainerEnvironment) { exportOptions.iCloudContainerEnvironment = buildOpts.iCloudContainerEnvironment; } if (buildOpts.developmentTeam) { exportOptions.teamID = buildOpts.developmentTeam; } if (buildOpts.provisioningProfile && bundleIdentifier) { if (typeof buildOpts.provisioningProfile === 'string') { exportOptions.provisioningProfiles = { [bundleIdentifier]: String(buildOpts.provisioningProfile) }; } else { events.emit('log', 'Setting multiple provisioning profiles for signing'); exportOptions.provisioningProfiles = buildOpts.provisioningProfile; } exportOptions.signingStyle = 'manual'; } if (buildOpts.codeSignIdentity) { exportOptions.signingCertificate = buildOpts.codeSignIdentity; } const exportOptionsPlist = plist.build(exportOptions); const exportOptionsPath = path.join(projectPath, 'exportOptions.plist'); const configuration = buildOpts.release ? 'Release' : 'Debug'; const buildOutputDir = path.join(projectPath, 'build', `${configuration}-iphoneos`); function checkSystemRuby () { const ruby_cmd = which.sync('ruby', { nothrow: true }); if (ruby_cmd !== '/usr/bin/ruby') { events.emit('warn', 'Non-system Ruby in use. This may cause packaging to fail.\n' + 'If you use RVM, please run `rvm use system`.\n' + 'If you use chruby, please run `chruby system`.'); } } function packageArchive () { const xcodearchiveArgs = getXcodeArchiveArgs(projectPath, buildOutputDir, exportOptionsPath, buildOpts); return execa('xcodebuild', xcodearchiveArgs, { cwd: projectPath, stdio: 'inherit' }); } return fs.promises.writeFile(exportOptionsPath, exportOptionsPlist, 'utf-8') .then(checkSystemRuby) .then(packageArchive); }) .then(() => {}); // resolve to undefined }; /** * Returns array of arguments for xcodebuild * @param {String} projectPath Path to project file. Will be used to set CWD for xcodebuild * @param {String} configuration Configuration name: debug|release * @param {String} emulatorTarget Target for emulator (rather than default) * @param {Object} buildConfig The build configuration options * @return {Array} Array of arguments that could be passed directly to spawn method */ function getXcodeBuildArgs (projectPath, configuration, emulatorTarget, buildConfig = {}) { let options; let buildActions; let settings; const buildFlags = buildConfig.buildFlag; const customArgs = {}; customArgs.otherFlags = []; if (buildFlags) { if (typeof buildFlags === 'string' || buildFlags instanceof String) { parseBuildFlag(buildFlags, customArgs); } else { // buildFlags is an Array of strings buildFlags.forEach(flag => { parseBuildFlag(flag, customArgs); }); } } if (buildConfig.device && !buildConfig.catalyst) { options = [ '-workspace', customArgs.workspace || 'App.xcworkspace', '-scheme', customArgs.scheme || 'App', '-configuration', customArgs.configuration || configuration, '-destination', customArgs.destination || 'generic/platform=iOS', '-archivePath', customArgs.archivePath || 'App.xcarchive' ]; buildActions = ['archive']; settings = []; if (customArgs.configuration_build_dir) { settings.push(customArgs.configuration_build_dir); } if (customArgs.shared_precomps_dir) { settings.push(customArgs.shared_precomps_dir); } // Add other matched flags to otherFlags to let xcodebuild present an appropriate error. // This is preferable to just ignoring the flags that the user has passed in. if (customArgs.sdk) { customArgs.otherFlags = customArgs.otherFlags.concat(['-sdk', customArgs.sdk]); } if (buildConfig.automaticProvisioning) { options.push('-allowProvisioningUpdates'); } if (buildConfig.authenticationKeyPath) { options.push('-authenticationKeyPath', buildConfig.authenticationKeyPath); } if (buildConfig.authenticationKeyID) { options.push('-authenticationKeyID', buildConfig.authenticationKeyID); } if (buildConfig.authenticationKeyIssuerID) { options.push('-authenticationKeyIssuerID', buildConfig.authenticationKeyIssuerID); } } else { // emulator options = [ '-workspace', customArgs.workspace || 'App.xcworkspace', '-scheme', customArgs.scheme || 'App', '-configuration', customArgs.configuration || configuration ]; if (buildConfig.catalyst) { options = options.concat([ '-destination', customArgs.destination || 'generic/platform=macOS,variant=Mac Catalyst' ]); } else { options = options.concat([ '-sdk', customArgs.sdk || 'iphonesimulator', '-destination', customArgs.destination || `platform=iOS Simulator,name=${emulatorTarget}` ]); } buildActions = ['build']; settings = []; if (customArgs.configuration_build_dir) { settings.push(customArgs.configuration_build_dir); } if (customArgs.shared_precomps_dir) { settings.push(customArgs.shared_precomps_dir); } // Add other matched flags to otherFlags to let xcodebuild present an appropriate error. // This is preferable to just ignoring the flags that the user has passed in. if (customArgs.archivePath) { customArgs.otherFlags = customArgs.otherFlags.concat(['-archivePath', customArgs.archivePath]); } } return options.concat(buildActions).concat(settings).concat(customArgs.otherFlags); } /** * Returns array of arguments for xcodebuild * @param {String} projectPath Path to project file. Will be used to set CWD for xcodebuild * @param {String} outputPath Output directory to contain the IPA * @param {String} exportOptionsPath Path to the exportOptions.plist file * @param {Object} buildConfig Build configuration options * @return {Array} Array of arguments that could be passed directly to spawn method */ function getXcodeArchiveArgs (projectPath, outputPath, exportOptionsPath, buildConfig = {}) { const options = []; const buildFlags = buildConfig.buildFlag; const customArgs = {}; customArgs.otherFlags = []; if (buildFlags) { if (typeof buildFlags === 'string' || buildFlags instanceof String) { parseBuildFlag(buildFlags, customArgs); } else { // buildFlags is an Array of strings buildFlags.forEach(flag => { parseBuildFlag(flag, customArgs); }); } } if (buildConfig.automaticProvisioning) { options.push('-allowProvisioningUpdates'); } if (buildConfig.authenticationKeyPath) { options.push('-authenticationKeyPath', buildConfig.authenticationKeyPath); } if (buildConfig.authenticationKeyID) { options.push('-authenticationKeyID', buildConfig.authenticationKeyID); } if (buildConfig.authenticationKeyIssuerID) { options.push('-authenticationKeyIssuerID', buildConfig.authenticationKeyIssuerID); } return [ '-exportArchive', '-archivePath', customArgs.archivePath || 'App.xcarchive', '-exportOptionsPlist', exportOptionsPath, '-exportPath', outputPath ].concat(options).concat(customArgs.otherFlags); } function parseBuildFlag (buildFlag, args) { let matched; for (const key in buildFlagMatchers) { const found = buildFlag.match(buildFlagMatchers[key]); if (found) { matched = true; // found[0] is the whole match, found[1] is the first match in parentheses. args[key] = found[1]; events.emit('warn', util.format('Overriding xcodebuildArg: %s', buildFlag)); } } if (!matched) { // If the flag starts with a '-' then it is an xcodebuild built-in option or a // user-defined setting. The regex makes sure that we don't split a user-defined // setting that is wrapped in quotes. if (buildFlag[0] === '-' && !buildFlag.match(/^[^=]+=(["'])(.*?[^\\])\1$/)) { args.otherFlags = args.otherFlags.concat(buildFlag.split(' ')); events.emit('warn', util.format('Adding xcodebuildArg: %s', buildFlag.split(' '))); } else { args.otherFlags.push(buildFlag); events.emit('warn', util.format('Adding xcodebuildArg: %s', buildFlag)); } } }