vn-picture/platforms/ios/cordova/lib/prepare.js

1173 lines
49 KiB
JavaScript
Executable File

/**
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.
*/
'use strict';
var Q = require('q');
var fs = require('fs');
var path = require('path');
var shell = require('shelljs');
var unorm = require('unorm');
var plist = require('plist');
var URL = require('url');
var events = require('cordova-common').events;
var xmlHelpers = require('cordova-common').xmlHelpers;
var ConfigParser = require('cordova-common').ConfigParser;
var CordovaError = require('cordova-common').CordovaError;
var PlatformJson = require('cordova-common').PlatformJson;
var PlatformMunger = require('cordova-common').ConfigChanges.PlatformMunger;
var PluginInfoProvider = require('cordova-common').PluginInfoProvider;
var FileUpdater = require('cordova-common').FileUpdater;
var projectFile = require('./projectFile');
// launch storyboard and related constants
var LAUNCHIMAGE_BUILD_SETTING = 'ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME';
var LAUNCHIMAGE_BUILD_SETTING_VALUE = 'LaunchImage';
var UI_LAUNCH_STORYBOARD_NAME = 'UILaunchStoryboardName';
var CDV_LAUNCH_STORYBOARD_NAME = 'CDVLaunchScreen';
var IMAGESET_COMPACT_SIZE_CLASS = 'compact';
var CDV_ANY_SIZE_CLASS = 'any';
module.exports.prepare = function (cordovaProject, options) {
var self = this;
var platformJson = PlatformJson.load(this.locations.root, 'ios');
var munger = new PlatformMunger('ios', this.locations.root, platformJson, new PluginInfoProvider());
this._config = updateConfigFile(cordovaProject.projectConfig, munger, this.locations);
// Update own www dir with project's www assets and plugins' assets and js-files
return Q.when(updateWww(cordovaProject, this.locations))
.then(function () {
// update project according to config.xml changes.
return updateProject(self._config, self.locations);
})
.then(function () {
updateIcons(cordovaProject, self.locations);
updateSplashScreens(cordovaProject, self.locations);
updateLaunchStoryboardImages(cordovaProject, self.locations);
updateFileResources(cordovaProject, self.locations);
})
.then(function () {
events.emit('verbose', 'Prepared iOS project successfully');
});
};
module.exports.clean = function (options) {
// A cordovaProject isn't passed into the clean() function, because it might have
// been called from the platform shell script rather than the CLI. Check for the
// noPrepare option passed in by the non-CLI clean script. If that's present, or if
// there's no config.xml found at the project root, then don't clean prepared files.
var projectRoot = path.resolve(this.root, '../..');
var projectConfigFile = path.join(projectRoot, 'config.xml');
if ((options && options.noPrepare) || !fs.existsSync(projectConfigFile) ||
!fs.existsSync(this.locations.configXml)) {
return Q();
}
var projectConfig = new ConfigParser(this.locations.configXml);
var self = this;
return Q().then(function () {
cleanWww(projectRoot, self.locations);
cleanIcons(projectRoot, projectConfig, self.locations);
cleanSplashScreens(projectRoot, projectConfig, self.locations);
cleanLaunchStoryboardImages(projectRoot, projectConfig, self.locations);
cleanFileResources(projectRoot, projectConfig, self.locations);
});
};
/**
* Updates config files in project based on app's config.xml and config munge,
* generated by plugins.
*
* @param {ConfigParser} sourceConfig A project's configuration that will
* be merged into platform's config.xml
* @param {ConfigChanges} configMunger An initialized ConfigChanges instance
* for this platform.
* @param {Object} locations A map of locations for this platform
*
* @return {ConfigParser} An instance of ConfigParser, that
* represents current project's configuration. When returned, the
* configuration is already dumped to appropriate config.xml file.
*/
function updateConfigFile (sourceConfig, configMunger, locations) {
events.emit('verbose', 'Generating platform-specific config.xml from defaults for iOS at ' + locations.configXml);
// First cleanup current config and merge project's one into own
// Overwrite platform config.xml with defaults.xml.
shell.cp('-f', locations.defaultConfigXml, locations.configXml);
// Then apply config changes from global munge to all config files
// in project (including project's config)
configMunger.reapply_global_munge().save_all();
events.emit('verbose', 'Merging project\'s config.xml into platform-specific iOS config.xml');
// Merge changes from app's config.xml into platform's one
var config = new ConfigParser(locations.configXml);
xmlHelpers.mergeXml(sourceConfig.doc.getroot(),
config.doc.getroot(), 'ios', /* clobber= */true);
config.write();
return config;
}
/**
* Logs all file operations via the verbose event stream, indented.
*/
function logFileOp (message) {
events.emit('verbose', ' ' + message);
}
/**
* Updates platform 'www' directory by replacing it with contents of
* 'platform_www' and app www. Also copies project's overrides' folder into
* the platform 'www' folder
*
* @param {Object} cordovaProject An object which describes cordova project.
* @param {boolean} destinations An object that contains destinations
* paths for www files.
*/
function updateWww (cordovaProject, destinations) {
var sourceDirs = [
path.relative(cordovaProject.root, cordovaProject.locations.www),
path.relative(cordovaProject.root, destinations.platformWww)
];
// If project contains 'merges' for our platform, use them as another overrides
var merges_path = path.join(cordovaProject.root, 'merges', 'ios');
if (fs.existsSync(merges_path)) {
events.emit('verbose', 'Found "merges/ios" folder. Copying its contents into the iOS project.');
sourceDirs.push(path.join('merges', 'ios'));
}
var targetDir = path.relative(cordovaProject.root, destinations.www);
events.emit(
'verbose', 'Merging and updating files from [' + sourceDirs.join(', ') + '] to ' + targetDir);
FileUpdater.mergeAndUpdateDir(
sourceDirs, targetDir, { rootDir: cordovaProject.root }, logFileOp);
}
/**
* Cleans all files from the platform 'www' directory.
*/
function cleanWww (projectRoot, locations) {
var targetDir = path.relative(projectRoot, locations.www);
events.emit('verbose', 'Cleaning ' + targetDir);
// No source paths are specified, so mergeAndUpdateDir() will clear the target directory.
FileUpdater.mergeAndUpdateDir(
[], targetDir, { rootDir: projectRoot, all: true }, logFileOp);
}
/**
* Updates project structure and AndroidManifest according to project's configuration.
*
* @param {ConfigParser} platformConfig A project's configuration that will
* be used to update project
* @param {Object} locations A map of locations for this platform (In/Out)
*/
function updateProject (platformConfig, locations) {
// CB-6992 it is necessary to normalize characters
// because node and shell scripts handles unicode symbols differently
// We need to normalize the name to NFD form since iOS uses NFD unicode form
var name = unorm.nfd(platformConfig.name());
var version = platformConfig.version();
var displayName = platformConfig.shortName && platformConfig.shortName();
var originalName = path.basename(locations.xcodeCordovaProj);
// Update package id (bundle id)
var plistFile = path.join(locations.xcodeCordovaProj, originalName + '-Info.plist');
var infoPlist = plist.parse(fs.readFileSync(plistFile, 'utf8'));
// Update version (bundle version)
infoPlist['CFBundleShortVersionString'] = version;
var CFBundleVersion = platformConfig.getAttribute('ios-CFBundleVersion') || default_CFBundleVersion(version);
infoPlist['CFBundleVersion'] = CFBundleVersion;
if (platformConfig.getAttribute('defaultlocale')) {
infoPlist['CFBundleDevelopmentRegion'] = platformConfig.getAttribute('defaultlocale');
}
if (displayName) {
infoPlist['CFBundleDisplayName'] = displayName;
}
// replace Info.plist ATS entries according to <access> and <allow-navigation> config.xml entries
var ats = writeATSEntries(platformConfig);
if (Object.keys(ats).length > 0) {
infoPlist['NSAppTransportSecurity'] = ats;
} else {
delete infoPlist['NSAppTransportSecurity'];
}
handleOrientationSettings(platformConfig, infoPlist);
updateProjectPlistForLaunchStoryboard(platformConfig, infoPlist);
/* eslint-disable no-tabs */
// Write out the plist file with the same formatting as Xcode does
var info_contents = plist.build(infoPlist, { indent: ' ', offset: -1 });
/* eslint-enable no-tabs */
info_contents = info_contents.replace(/<string>[\s\r\n]*<\/string>/g, '<string></string>');
fs.writeFileSync(plistFile, info_contents, 'utf-8');
events.emit('verbose', 'Wrote out iOS Bundle Version "' + version + '" to ' + plistFile);
return handleBuildSettings(platformConfig, locations, infoPlist).then(function () {
if (name === originalName) {
events.emit('verbose', 'iOS Product Name has not changed (still "' + originalName + '")');
return Q();
} else { // CB-11712 <name> was changed, we don't support it'
var errorString =
'The product name change (<name> tag) in config.xml is not supported dynamically.\n' +
'To change your product name, you have to remove, then add your ios platform again.\n' +
'Make sure you save your plugins beforehand using `cordova plugin save`.\n' +
'\tcordova plugin save\n' +
'\tcordova platform rm ios\n' +
'\tcordova platform add ios\n'
;
return Q.reject(new CordovaError(errorString));
}
});
}
function handleOrientationSettings (platformConfig, infoPlist) {
switch (getOrientationValue(platformConfig)) {
case 'portrait':
infoPlist['UIInterfaceOrientation'] = [ 'UIInterfaceOrientationPortrait' ];
infoPlist['UISupportedInterfaceOrientations'] = [ 'UIInterfaceOrientationPortrait', 'UIInterfaceOrientationPortraitUpsideDown' ];
infoPlist['UISupportedInterfaceOrientations~ipad'] = [ 'UIInterfaceOrientationPortrait', 'UIInterfaceOrientationPortraitUpsideDown' ];
break;
case 'landscape':
infoPlist['UIInterfaceOrientation'] = [ 'UIInterfaceOrientationLandscapeLeft' ];
infoPlist['UISupportedInterfaceOrientations'] = [ 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight' ];
infoPlist['UISupportedInterfaceOrientations~ipad'] = [ 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight' ];
break;
case 'all':
infoPlist['UIInterfaceOrientation'] = [ 'UIInterfaceOrientationPortrait' ];
infoPlist['UISupportedInterfaceOrientations'] = [ 'UIInterfaceOrientationPortrait', 'UIInterfaceOrientationPortraitUpsideDown', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight' ];
infoPlist['UISupportedInterfaceOrientations~ipad'] = [ 'UIInterfaceOrientationPortrait', 'UIInterfaceOrientationPortraitUpsideDown', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight' ];
break;
case 'default':
infoPlist['UISupportedInterfaceOrientations'] = [ 'UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight' ];
infoPlist['UISupportedInterfaceOrientations~ipad'] = [ 'UIInterfaceOrientationPortrait', 'UIInterfaceOrientationPortraitUpsideDown', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight' ];
delete infoPlist['UIInterfaceOrientation'];
}
}
function handleBuildSettings (platformConfig, locations, infoPlist) {
var pkg = platformConfig.getAttribute('ios-CFBundleIdentifier') || platformConfig.packageName();
var targetDevice = parseTargetDevicePreference(platformConfig.getPreference('target-device', 'ios'));
var deploymentTarget = platformConfig.getPreference('deployment-target', 'ios');
var needUpdatedBuildSettingsForLaunchStoryboard = checkIfBuildSettingsNeedUpdatedForLaunchStoryboard(platformConfig, infoPlist);
var swiftVersion = platformConfig.getPreference('SwiftVersion', 'ios');
var project;
try {
project = projectFile.parse(locations);
} catch (err) {
return Q.reject(new CordovaError('Could not parse ' + locations.pbxproj + ': ' + err));
}
var origPkg = project.xcode.getBuildProperty('PRODUCT_BUNDLE_IDENTIFIER');
// no build settings provided and we don't need to update build settings for launch storyboards,
// then we don't need to parse and update .pbxproj file
if (origPkg === pkg && !targetDevice && !deploymentTarget && !needUpdatedBuildSettingsForLaunchStoryboard && !swiftVersion) {
return Q();
}
if (origPkg !== pkg) {
events.emit('verbose', 'Set PRODUCT_BUNDLE_IDENTIFIER to ' + pkg + '.');
project.xcode.updateBuildProperty('PRODUCT_BUNDLE_IDENTIFIER', pkg);
}
if (targetDevice) {
events.emit('verbose', 'Set TARGETED_DEVICE_FAMILY to ' + targetDevice + '.');
project.xcode.updateBuildProperty('TARGETED_DEVICE_FAMILY', targetDevice);
}
if (deploymentTarget) {
events.emit('verbose', 'Set IPHONEOS_DEPLOYMENT_TARGET to "' + deploymentTarget + '".');
project.xcode.updateBuildProperty('IPHONEOS_DEPLOYMENT_TARGET', deploymentTarget);
}
if (swiftVersion) {
events.emit('verbose', 'Set SwiftVersion to "' + swiftVersion + '".');
project.xcode.updateBuildProperty('SWIFT_VERSION', swiftVersion);
}
updateBuildSettingsForLaunchStoryboard(project.xcode, platformConfig, infoPlist);
project.write();
return Q();
}
function mapIconResources (icons, iconsDir) {
// See https://developer.apple.com/library/ios/documentation/UserExperience/Conceptual/MobileHIG/IconMatrix.html
// for launch images sizes reference.
var platformIcons = [
{ dest: 'icon-20.png', width: 20, height: 20 },
{ dest: 'icon-20@2x.png', width: 40, height: 40 },
{ dest: 'icon-20@3x.png', width: 60, height: 60 },
{ dest: 'icon-40.png', width: 40, height: 40 },
{ dest: 'icon-40@2x.png', width: 80, height: 80 },
{ dest: 'icon-50.png', width: 50, height: 50 },
{ dest: 'icon-50@2x.png', width: 100, height: 100 },
{ dest: 'icon-60@2x.png', width: 120, height: 120 },
{ dest: 'icon-60@3x.png', width: 180, height: 180 },
{ dest: 'icon-72.png', width: 72, height: 72 },
{ dest: 'icon-72@2x.png', width: 144, height: 144 },
{ dest: 'icon-76.png', width: 76, height: 76 },
{ dest: 'icon-76@2x.png', width: 152, height: 152 },
{ dest: 'icon-83.5@2x.png', width: 167, height: 167 },
{ dest: 'icon-1024.png', width: 1024, height: 1024 },
{ dest: 'icon-29.png', width: 29, height: 29 },
{ dest: 'icon-29@2x.png', width: 58, height: 58 },
{ dest: 'icon-29@3x.png', width: 87, height: 87 },
{ dest: 'icon.png', width: 57, height: 57 },
{ dest: 'icon@2x.png', width: 114, height: 114 },
{ dest: 'icon-24@2x.png', width: 48, height: 48 },
{ dest: 'icon-27.5@2x.png', width: 55, height: 55 },
{ dest: 'icon-44@2x.png', width: 88, height: 88 },
{ dest: 'icon-86@2x.png', width: 172, height: 172 },
{ dest: 'icon-98@2x.png', width: 196, height: 196 }
];
var pathMap = {};
platformIcons.forEach(function (item) {
var icon = icons.getBySize(item.width, item.height) || icons.getDefault();
if (icon) {
var target = path.join(iconsDir, item.dest);
pathMap[target] = icon.src;
}
});
return pathMap;
}
function getIconsDir (projectRoot, platformProjDir) {
var iconsDir;
var xcassetsExists = folderExists(path.join(projectRoot, platformProjDir, 'Images.xcassets/'));
if (xcassetsExists) {
iconsDir = path.join(platformProjDir, 'Images.xcassets/AppIcon.appiconset/');
} else {
iconsDir = path.join(platformProjDir, 'Resources/icons/');
}
return iconsDir;
}
function updateIcons (cordovaProject, locations) {
var icons = cordovaProject.projectConfig.getIcons('ios');
if (icons.length === 0) {
events.emit('verbose', 'This app does not have icons defined');
return;
}
var platformProjDir = path.relative(cordovaProject.root, locations.xcodeCordovaProj);
var iconsDir = getIconsDir(cordovaProject.root, platformProjDir);
var resourceMap = mapIconResources(icons, iconsDir);
events.emit('verbose', 'Updating icons at ' + iconsDir);
FileUpdater.updatePaths(
resourceMap, { rootDir: cordovaProject.root }, logFileOp);
}
function cleanIcons (projectRoot, projectConfig, locations) {
var icons = projectConfig.getIcons('ios');
if (icons.length > 0) {
var platformProjDir = path.relative(projectRoot, locations.xcodeCordovaProj);
var iconsDir = getIconsDir(projectRoot, platformProjDir);
var resourceMap = mapIconResources(icons, iconsDir);
Object.keys(resourceMap).forEach(function (targetIconPath) {
resourceMap[targetIconPath] = null;
});
events.emit('verbose', 'Cleaning icons at ' + iconsDir);
// Source paths are removed from the map, so updatePaths() will delete the target files.
FileUpdater.updatePaths(
resourceMap, { rootDir: projectRoot, all: true }, logFileOp);
}
}
function mapSplashScreenResources (splashScreens, splashScreensDir) {
var platformSplashScreens = [
{ dest: 'Default~iphone.png', width: 320, height: 480 },
{ dest: 'Default@2x~iphone.png', width: 640, height: 960 },
{ dest: 'Default-Portrait~ipad.png', width: 768, height: 1024 },
{ dest: 'Default-Portrait@2x~ipad.png', width: 1536, height: 2048 },
{ dest: 'Default-Landscape~ipad.png', width: 1024, height: 768 },
{ dest: 'Default-Landscape@2x~ipad.png', width: 2048, height: 1536 },
{ dest: 'Default-568h@2x~iphone.png', width: 640, height: 1136 },
{ dest: 'Default-667h.png', width: 750, height: 1334 },
{ dest: 'Default-736h.png', width: 1242, height: 2208 },
{ dest: 'Default-Landscape-736h.png', width: 2208, height: 1242 },
{ dest: 'Default-2436h.png', width: 1125, height: 2436 },
{ dest: 'Default-Landscape-2436h.png', width: 2436, height: 1125 }
];
var pathMap = {};
platformSplashScreens.forEach(function (item) {
var splash = splashScreens.getBySize(item.width, item.height);
if (splash) {
var target = path.join(splashScreensDir, item.dest);
pathMap[target] = splash.src;
}
});
return pathMap;
}
function getSplashScreensDir (projectRoot, platformProjDir) {
var splashScreensDir;
var xcassetsExists = folderExists(path.join(projectRoot, platformProjDir, 'Images.xcassets/'));
if (xcassetsExists) {
splashScreensDir = path.join(platformProjDir, 'Images.xcassets/LaunchImage.launchimage/');
} else {
splashScreensDir = path.join(platformProjDir, 'Resources/splash/');
}
return splashScreensDir;
}
function updateSplashScreens (cordovaProject, locations) {
var splashScreens = cordovaProject.projectConfig.getSplashScreens('ios');
if (splashScreens.length === 0) {
events.emit('verbose', 'This app does not have splash screens defined');
return;
}
var platformProjDir = path.relative(cordovaProject.root, locations.xcodeCordovaProj);
var splashScreensDir = getSplashScreensDir(cordovaProject.root, platformProjDir);
var resourceMap = mapSplashScreenResources(splashScreens, splashScreensDir);
events.emit('verbose', 'Updating splash screens at ' + splashScreensDir);
FileUpdater.updatePaths(
resourceMap, { rootDir: cordovaProject.root }, logFileOp);
}
function cleanSplashScreens (projectRoot, projectConfig, locations) {
var splashScreens = projectConfig.getSplashScreens('ios');
if (splashScreens.length > 0) {
var platformProjDir = path.relative(projectRoot, locations.xcodeCordovaProj);
var splashScreensDir = getSplashScreensDir(projectRoot, platformProjDir);
var resourceMap = mapIconResources(splashScreens, splashScreensDir);
Object.keys(resourceMap).forEach(function (targetSplashPath) {
resourceMap[targetSplashPath] = null;
});
events.emit('verbose', 'Cleaning splash screens at ' + splashScreensDir);
// Source paths are removed from the map, so updatePaths() will delete the target files.
FileUpdater.updatePaths(
resourceMap, { rootDir: projectRoot, all: true }, logFileOp);
}
}
function updateFileResources (cordovaProject, locations) {
const platformDir = path.relative(cordovaProject.root, locations.root);
const files = cordovaProject.projectConfig.getFileResources('ios');
const project = projectFile.parse(locations);
// if there are resource-file elements in config.xml
if (files.length === 0) {
events.emit('verbose', 'This app does not have additional resource files defined');
return;
}
let resourceMap = {};
files.forEach(function (res) {
let src = res.src;
let target = res.target;
if (!target) {
target = src;
}
let targetPath = path.join(project.resources_dir, target);
targetPath = path.relative(cordovaProject.root, targetPath);
if (!fs.existsSync(targetPath)) {
project.xcode.addResourceFile(target);
} else {
events.emit('warn', 'Overwriting existing resource file at ' + targetPath);
}
resourceMap[targetPath] = src;
});
events.emit('verbose', 'Updating resource files at ' + platformDir);
FileUpdater.updatePaths(
resourceMap, { rootDir: cordovaProject.root }, logFileOp);
project.write();
}
function cleanFileResources (projectRoot, projectConfig, locations) {
const platformDir = path.relative(projectRoot, locations.root);
const files = projectConfig.getFileResources('ios', true);
if (files.length > 0) {
events.emit('verbose', 'Cleaning resource files at ' + platformDir);
const project = projectFile.parse(locations);
var resourceMap = {};
files.forEach(function (res) {
let src = res.src;
let target = res.target;
if (!target) {
target = src;
}
let targetPath = path.join(project.resources_dir, target);
targetPath = path.relative(projectRoot, targetPath);
const resfile = path.join('Resources', path.basename(targetPath));
project.xcode.removeResourceFile(resfile);
resourceMap[targetPath] = null;
});
FileUpdater.updatePaths(
resourceMap, { rootDir: projectRoot, all: true }, logFileOp);
project.write();
}
}
/**
* Returns an array of images for each possible idiom, scale, and size class. The images themselves are
* located in the platform's splash images by their pattern (@scale~idiom~sizesize). All possible
* combinations are returned, but not all will have a `filename` property. If the latter isn't present,
* the device won't attempt to load an image matching the same traits. If the filename is present,
* the device will try to load the image if it corresponds to the traits.
*
* The resulting return looks like this:
*
* [
* {
* idiom: 'universal|ipad|iphone',
* scale: '1x|2x|3x',
* width: 'any|com',
* height: 'any|com',
* filename: undefined|'Default@scale~idiom~widthheight.png',
* src: undefined|'path/to/original/matched/image/from/splash/screens.png',
* target: undefined|'path/to/asset/library/Default@scale~idiom~widthheight.png'
* }, ...
* ]
*
* @param {Array<Object>} splashScreens splash screens as defined in config.xml for this platform
* @param {string} launchStoryboardImagesDir project-root/Images.xcassets/LaunchStoryboard.imageset/
* @return {Array<Object>}
*/
function mapLaunchStoryboardContents (splashScreens, launchStoryboardImagesDir) {
var platformLaunchStoryboardImages = [];
var idioms = ['universal', 'ipad', 'iphone'];
var scalesForIdiom = {
universal: ['1x', '2x', '3x'],
ipad: ['1x', '2x'],
iphone: ['1x', '2x', '3x']
};
var sizes = ['com', 'any'];
idioms.forEach(function (idiom) {
scalesForIdiom[idiom].forEach(function (scale) {
sizes.forEach(function (width) {
sizes.forEach(function (height) {
var item = {
idiom: idiom,
scale: scale,
width: width,
height: height
};
/* examples of the search pattern:
* scale ~ idiom ~ width height
* @2x ~ universal ~ any any
* @3x ~ iphone ~ com any
* @2x ~ ipad ~ com any
*/
var searchPattern = '@' + scale + '~' + idiom + '~' + width + height;
/* because old node versions don't have Array.find, the below is
* functionally equivalent to this:
* var launchStoryboardImage = splashScreens.find(function(item) {
* return item.src.indexOf(searchPattern) >= 0;
* });
*/
var launchStoryboardImage = splashScreens.reduce(function (p, c) {
return (c.src.indexOf(searchPattern) >= 0) ? c : p;
}, undefined);
if (launchStoryboardImage) {
item.filename = 'Default' + searchPattern + '.png';
item.src = launchStoryboardImage.src;
item.target = path.join(launchStoryboardImagesDir, item.filename);
}
platformLaunchStoryboardImages.push(item);
});
});
});
});
return platformLaunchStoryboardImages;
}
/**
* Returns a dictionary representing the source and destination paths for the launch storyboard images
* that need to be copied.
*
* The resulting return looks like this:
*
* {
* 'target-path': 'source-path',
* ...
* }
*
* @param {Array<Object>} splashScreens splash screens as defined in config.xml for this platform
* @param {string} launchStoryboardImagesDir project-root/Images.xcassets/LaunchStoryboard.imageset/
* @return {Object}
*/
function mapLaunchStoryboardResources (splashScreens, launchStoryboardImagesDir) {
var platformLaunchStoryboardImages = mapLaunchStoryboardContents(splashScreens, launchStoryboardImagesDir);
var pathMap = {};
platformLaunchStoryboardImages.forEach(function (item) {
if (item.target) {
pathMap[item.target] = item.src;
}
});
return pathMap;
}
/**
* Builds the object that represents the contents.json file for the LaunchStoryboard image set.
*
* The resulting return looks like this:
*
* {
* images: [
* {
* idiom: 'universal|ipad|iphone',
* scale: '1x|2x|3x',
* width-class: undefined|'compact',
* height-class: undefined|'compact'
* }, ...
* ],
* info: {
* author: 'Xcode',
* version: 1
* }
* }
*
* A bit of minor logic is used to map from the array of images returned from mapLaunchStoryboardContents
* to the format requried by Xcode.
*
* @param {Array<Object>} splashScreens splash screens as defined in config.xml for this platform
* @param {string} launchStoryboardImagesDir project-root/Images.xcassets/LaunchStoryboard.imageset/
* @return {Object}
*/
function getLaunchStoryboardContentsJSON (splashScreens, launchStoryboardImagesDir) {
var platformLaunchStoryboardImages = mapLaunchStoryboardContents(splashScreens, launchStoryboardImagesDir);
var contentsJSON = {
images: [],
info: {
author: 'Xcode',
version: 1
}
};
contentsJSON.images = platformLaunchStoryboardImages.map(function (item) {
var newItem = {
idiom: item.idiom,
scale: item.scale
};
// Xcode doesn't want any size class property if the class is "any"
// If our size class is "com", Xcode wants "compact".
if (item.width !== CDV_ANY_SIZE_CLASS) {
newItem['width-class'] = IMAGESET_COMPACT_SIZE_CLASS;
}
if (item.height !== CDV_ANY_SIZE_CLASS) {
newItem['height-class'] = IMAGESET_COMPACT_SIZE_CLASS;
}
// Xcode doesn't want a filename property if there's no image for these traits
if (item.filename) {
newItem.filename = item.filename;
}
return newItem;
});
return contentsJSON;
}
/**
* Determines if the project's build settings may need to be updated for launch storyboard support
*
*/
function checkIfBuildSettingsNeedUpdatedForLaunchStoryboard (platformConfig, infoPlist) {
var hasLaunchStoryboardImages = platformHasLaunchStoryboardImages(platformConfig);
var hasLegacyLaunchImages = platformHasLegacyLaunchImages(platformConfig);
var currentLaunchStoryboard = infoPlist[UI_LAUNCH_STORYBOARD_NAME];
if (hasLaunchStoryboardImages && currentLaunchStoryboard === CDV_LAUNCH_STORYBOARD_NAME && !hasLegacyLaunchImages) {
// don't need legacy launch images if we are using our launch storyboard
// so we do need to update the project file
events.emit('verbose', 'Need to update build settings because project is using our launch storyboard.');
return true;
} else if (hasLegacyLaunchImages && !currentLaunchStoryboard) {
// we do need to ensure legacy launch images are used if there's no launch storyboard present
// so we do need to update the project file
events.emit('verbose', 'Need to update build settings because project is using legacy launch images and no storyboard.');
return true;
}
events.emit('verbose', 'No need to update build settings for launch storyboard support.');
return false;
}
function updateBuildSettingsForLaunchStoryboard (proj, platformConfig, infoPlist) {
var hasLaunchStoryboardImages = platformHasLaunchStoryboardImages(platformConfig);
var hasLegacyLaunchImages = platformHasLegacyLaunchImages(platformConfig);
var currentLaunchStoryboard = infoPlist[UI_LAUNCH_STORYBOARD_NAME];
if (hasLaunchStoryboardImages && currentLaunchStoryboard === CDV_LAUNCH_STORYBOARD_NAME && !hasLegacyLaunchImages) {
// don't need legacy launch images if we are using our launch storyboard
events.emit('verbose', 'Removed ' + LAUNCHIMAGE_BUILD_SETTING + ' because project is using our launch storyboard.');
proj.removeBuildProperty(LAUNCHIMAGE_BUILD_SETTING);
} else if (hasLegacyLaunchImages && !currentLaunchStoryboard) {
// we do need to ensure legacy launch images are used if there's no launch storyboard present
events.emit('verbose', 'Set ' + LAUNCHIMAGE_BUILD_SETTING + ' to ' + LAUNCHIMAGE_BUILD_SETTING_VALUE + ' because project is using legacy launch images and no storyboard.');
proj.updateBuildProperty(LAUNCHIMAGE_BUILD_SETTING, LAUNCHIMAGE_BUILD_SETTING_VALUE);
} else {
events.emit('verbose', 'Did not update build settings for launch storyboard support.');
}
}
function splashScreensHaveLaunchStoryboardImages (contentsJSON) {
/* do we have any launch images do we have for our launch storyboard?
* Again, for old Node versions, the below code is equivalent to this:
* return !!contentsJSON.images.find(function (item) {
* return item.filename !== undefined;
* });
*/
return !!contentsJSON.images.reduce(function (p, c) {
return (c.filename !== undefined) ? c : p;
}, undefined);
}
function platformHasLaunchStoryboardImages (platformConfig) {
var splashScreens = platformConfig.getSplashScreens('ios');
var contentsJSON = getLaunchStoryboardContentsJSON(splashScreens, ''); // note: we don't need a file path here; we're just counting
return splashScreensHaveLaunchStoryboardImages(contentsJSON);
}
function platformHasLegacyLaunchImages (platformConfig) {
var splashScreens = platformConfig.getSplashScreens('ios');
return !!splashScreens.reduce(function (p, c) {
return (c.width !== undefined || c.height !== undefined) ? c : p;
}, undefined);
}
/**
* Updates the project's plist based upon our launch storyboard images. If there are no images, then we should
* fall back to the regular launch images that might be supplied (that is, our app will be scaled on an iPad Pro),
* and if there are some images, we need to alter the UILaunchStoryboardName property to point to
* CDVLaunchScreen.
*
* There's some logic here to avoid overwriting changes the user might have made to their plist if they are using
* their own launch storyboard.
*/
function updateProjectPlistForLaunchStoryboard (platformConfig, infoPlist) {
var currentLaunchStoryboard = infoPlist[UI_LAUNCH_STORYBOARD_NAME];
events.emit('verbose', 'Current launch storyboard ' + currentLaunchStoryboard);
var hasLaunchStoryboardImages = platformHasLaunchStoryboardImages(platformConfig);
if (hasLaunchStoryboardImages && !currentLaunchStoryboard) {
// only change the launch storyboard if we have images to use AND the current value is blank
// if it's not blank, we've either done this before, or the user has their own launch storyboard
events.emit('verbose', 'Changing info plist to use our launch storyboard');
infoPlist[UI_LAUNCH_STORYBOARD_NAME] = CDV_LAUNCH_STORYBOARD_NAME;
return;
}
if (!hasLaunchStoryboardImages && currentLaunchStoryboard === CDV_LAUNCH_STORYBOARD_NAME) {
// only revert to using the launch images if we have don't have any images for the launch storyboard
// but only clear it if current launch storyboard is our storyboard; the user might be using their
// own storyboard instead.
events.emit('verbose', 'Changing info plist to use legacy launch images');
delete infoPlist[UI_LAUNCH_STORYBOARD_NAME];
return;
}
events.emit('verbose', 'Not changing launch storyboard setting in info plist.');
}
/**
* Returns the directory for the Launch Storyboard image set, if image sets are being used. If they aren't
* being used, returns null.
*
* @param {string} projectRoot The project's root directory
* @param {string} platformProjDir The platform's project directory
*/
function getLaunchStoryboardImagesDir (projectRoot, platformProjDir) {
var launchStoryboardImagesDir;
var xcassetsExists = folderExists(path.join(projectRoot, platformProjDir, 'Images.xcassets/'));
if (xcassetsExists) {
launchStoryboardImagesDir = path.join(platformProjDir, 'Images.xcassets/LaunchStoryboard.imageset/');
} else {
// if we don't have a asset library for images, we can't do the storyboard.
launchStoryboardImagesDir = null;
}
return launchStoryboardImagesDir;
}
/**
* Update the images for the Launch Storyboard and updates the image set's contents.json file appropriately.
*
* @param {Object} cordovaProject The cordova project
* @param {Object} locations A dictionary containing useful location paths
*/
function updateLaunchStoryboardImages (cordovaProject, locations) {
var splashScreens = cordovaProject.projectConfig.getSplashScreens('ios');
var platformProjDir = path.relative(cordovaProject.root, locations.xcodeCordovaProj);
var launchStoryboardImagesDir = getLaunchStoryboardImagesDir(cordovaProject.root, platformProjDir);
if (launchStoryboardImagesDir) {
var resourceMap = mapLaunchStoryboardResources(splashScreens, launchStoryboardImagesDir);
var contentsJSON = getLaunchStoryboardContentsJSON(splashScreens, launchStoryboardImagesDir);
events.emit('verbose', 'Updating launch storyboard images at ' + launchStoryboardImagesDir);
FileUpdater.updatePaths(
resourceMap, { rootDir: cordovaProject.root }, logFileOp);
events.emit('verbose', 'Updating Storyboard image set contents.json');
fs.writeFileSync(path.join(cordovaProject.root, launchStoryboardImagesDir, 'Contents.json'),
JSON.stringify(contentsJSON, null, 2));
}
}
/**
* Removes the images from the launch storyboard's image set and updates the image set's contents.json
* file appropriately.
*
* @param {string} projectRoot Path to the project root
* @param {Object} projectConfig The project's config.xml
* @param {Object} locations A dictionary containing useful location paths
*/
function cleanLaunchStoryboardImages (projectRoot, projectConfig, locations) {
var splashScreens = projectConfig.getSplashScreens('ios');
var platformProjDir = path.relative(projectRoot, locations.xcodeCordovaProj);
var launchStoryboardImagesDir = getLaunchStoryboardImagesDir(projectRoot, platformProjDir);
if (launchStoryboardImagesDir) {
var resourceMap = mapLaunchStoryboardResources(splashScreens, launchStoryboardImagesDir);
var contentsJSON = getLaunchStoryboardContentsJSON(splashScreens, launchStoryboardImagesDir);
Object.keys(resourceMap).forEach(function (targetPath) {
resourceMap[targetPath] = null;
});
events.emit('verbose', 'Cleaning storyboard image set at ' + launchStoryboardImagesDir);
// Source paths are removed from the map, so updatePaths() will delete the target files.
FileUpdater.updatePaths(
resourceMap, { rootDir: projectRoot, all: true }, logFileOp);
// delete filename from contents.json
contentsJSON.images.forEach(function (image) {
image.filename = undefined;
});
events.emit('verbose', 'Updating Storyboard image set contents.json');
fs.writeFileSync(path.join(projectRoot, launchStoryboardImagesDir, 'Contents.json'),
JSON.stringify(contentsJSON, null, 2));
}
}
/**
* Queries ConfigParser object for the orientation <preference> value. Warns if
* global preference value is not supported by platform.
*
* @param {Object} platformConfig ConfigParser object
*
* @return {String} Global/platform-specific orientation in lower-case
* (or empty string if both are undefined).
*/
function getOrientationValue (platformConfig) {
var ORIENTATION_DEFAULT = 'default';
var orientation = platformConfig.getPreference('orientation');
if (!orientation) {
return '';
}
orientation = orientation.toLowerCase();
// Check if the given global orientation is supported
if (['default', 'portrait', 'landscape', 'all'].indexOf(orientation) >= 0) {
return orientation;
}
events.emit('warn', 'Unrecognized value for Orientation preference: ' + orientation +
'. Defaulting to value: ' + ORIENTATION_DEFAULT + '.');
return ORIENTATION_DEFAULT;
}
/*
Parses all <access> and <allow-navigation> entries and consolidates duplicates (for ATS).
Returns an object with a Hostname as the key, and the value an object with properties:
{
Hostname, // String
NSExceptionAllowsInsecureHTTPLoads, // boolean
NSIncludesSubdomains, // boolean
NSExceptionMinimumTLSVersion, // String
NSExceptionRequiresForwardSecrecy, // boolean
NSRequiresCertificateTransparency, // boolean
// the three below _only_ show when the Hostname is '*'
// if any of the three are set, it disables setting NSAllowsArbitraryLoads
// (Apple already enforces this in ATS)
NSAllowsArbitraryLoadsInWebContent, // boolean (default: false)
NSAllowsLocalNetworking, // boolean (default: false)
NSAllowsArbitraryLoadsForMedia, // boolean (default:false)
}
*/
function processAccessAndAllowNavigationEntries (config) {
var accesses = config.getAccesses();
var allow_navigations = config.getAllowNavigations();
return allow_navigations
// we concat allow_navigations and accesses, after processing accesses
.concat(accesses.map(function (obj) {
// map accesses to a common key interface using 'href', not origin
obj.href = obj.origin;
delete obj.origin;
return obj;
}))
// we reduce the array to an object with all the entries processed (key is Hostname)
.reduce(function (previousReturn, currentElement) {
var options = {
minimum_tls_version: currentElement.minimum_tls_version,
requires_forward_secrecy: currentElement.requires_forward_secrecy,
requires_certificate_transparency: currentElement.requires_certificate_transparency,
allows_arbitrary_loads_for_media: currentElement.allows_arbitrary_loads_in_media || currentElement.allows_arbitrary_loads_for_media,
allows_arbitrary_loads_in_web_content: currentElement.allows_arbitrary_loads_in_web_content,
allows_local_networking: currentElement.allows_local_networking
};
var obj = parseWhitelistUrlForATS(currentElement.href, options);
if (obj) {
// we 'union' duplicate entries
var item = previousReturn[obj.Hostname];
if (!item) {
item = {};
}
for (var o in obj) {
if (obj.hasOwnProperty(o)) {
item[o] = obj[o];
}
}
previousReturn[obj.Hostname] = item;
}
return previousReturn;
}, {});
}
/*
Parses a URL and returns an object with these keys:
{
Hostname, // String
NSExceptionAllowsInsecureHTTPLoads, // boolean (default: false)
NSIncludesSubdomains, // boolean (default: false)
NSExceptionMinimumTLSVersion, // String (default: 'TLSv1.2')
NSExceptionRequiresForwardSecrecy, // boolean (default: true)
NSRequiresCertificateTransparency, // boolean (default: false)
// the three below _only_ apply when the Hostname is '*'
// if any of the three are set, it disables setting NSAllowsArbitraryLoads
// (Apple already enforces this in ATS)
NSAllowsArbitraryLoadsInWebContent, // boolean (default: false)
NSAllowsLocalNetworking, // boolean (default: false)
NSAllowsArbitraryLoadsForMedia, // boolean (default:false)
}
null is returned if the URL cannot be parsed, or is to be skipped for ATS.
*/
function parseWhitelistUrlForATS (url, options) {
// @todo 'url.parse' was deprecated since v11.0.0. Use 'url.URL' constructor instead.
var href = URL.parse(url); // eslint-disable-line
var retObj = {};
retObj.Hostname = href.hostname;
// Guiding principle: we only set values in retObj if they are NOT the default
if (url === '*') {
retObj.Hostname = '*';
var val;
val = (options.allows_arbitrary_loads_in_web_content === 'true');
if (options.allows_arbitrary_loads_in_web_content && val) { // default is false
retObj.NSAllowsArbitraryLoadsInWebContent = true;
}
val = (options.allows_arbitrary_loads_for_media === 'true');
if (options.allows_arbitrary_loads_for_media && val) { // default is false
retObj.NSAllowsArbitraryLoadsForMedia = true;
}
val = (options.allows_local_networking === 'true');
if (options.allows_local_networking && val) { // default is false
retObj.NSAllowsLocalNetworking = true;
}
return retObj;
}
if (!retObj.Hostname) {
// check origin, if it allows subdomains (wildcard in hostname), we set NSIncludesSubdomains to YES. Default is NO
var subdomain1 = '/*.'; // wildcard in hostname
var subdomain2 = '*://*.'; // wildcard in hostname and protocol
var subdomain3 = '*://'; // wildcard in protocol only
if (!href.pathname) {
return null;
} else if (href.pathname.indexOf(subdomain1) === 0) {
retObj.NSIncludesSubdomains = true;
retObj.Hostname = href.pathname.substring(subdomain1.length);
} else if (href.pathname.indexOf(subdomain2) === 0) {
retObj.NSIncludesSubdomains = true;
retObj.Hostname = href.pathname.substring(subdomain2.length);
} else if (href.pathname.indexOf(subdomain3) === 0) {
retObj.Hostname = href.pathname.substring(subdomain3.length);
} else {
// Handling "scheme:*" case to avoid creating of a blank key in NSExceptionDomains.
return null;
}
}
if (options.minimum_tls_version && options.minimum_tls_version !== 'TLSv1.2') { // default is TLSv1.2
retObj.NSExceptionMinimumTLSVersion = options.minimum_tls_version;
}
var rfs = (options.requires_forward_secrecy === 'true');
if (options.requires_forward_secrecy && !rfs) { // default is true
retObj.NSExceptionRequiresForwardSecrecy = false;
}
var rct = (options.requires_certificate_transparency === 'true');
if (options.requires_certificate_transparency && rct) { // default is false
retObj.NSRequiresCertificateTransparency = true;
}
// if the scheme is HTTP, we set NSExceptionAllowsInsecureHTTPLoads to YES. Default is NO
if (href.protocol === 'http:') {
retObj.NSExceptionAllowsInsecureHTTPLoads = true;
} else if (!href.protocol && href.pathname.indexOf('*:/') === 0) { // wilcard in protocol
retObj.NSExceptionAllowsInsecureHTTPLoads = true;
}
return retObj;
}
/*
App Transport Security (ATS) writer from <access> and <allow-navigation> tags
in config.xml
*/
function writeATSEntries (config) {
var pObj = processAccessAndAllowNavigationEntries(config);
var ats = {};
for (var hostname in pObj) {
if (pObj.hasOwnProperty(hostname)) {
var entry = pObj[hostname];
// Guiding principle: we only set values if they are available
if (hostname === '*') {
// always write this, for iOS 9, since in iOS 10 it will be overriden if
// any of the other three keys are written
ats['NSAllowsArbitraryLoads'] = true;
// at least one of the overriding keys is present
if (entry.NSAllowsArbitraryLoadsInWebContent) {
ats['NSAllowsArbitraryLoadsInWebContent'] = true;
}
if (entry.NSAllowsArbitraryLoadsForMedia) {
ats['NSAllowsArbitraryLoadsForMedia'] = true;
}
if (entry.NSAllowsLocalNetworking) {
ats['NSAllowsLocalNetworking'] = true;
}
continue;
}
var exceptionDomain = {};
for (var key in entry) {
if (entry.hasOwnProperty(key) && key !== 'Hostname') {
exceptionDomain[key] = entry[key];
}
}
if (!ats['NSExceptionDomains']) {
ats['NSExceptionDomains'] = {};
}
ats['NSExceptionDomains'][hostname] = exceptionDomain;
}
}
return ats;
}
function folderExists (folderPath) {
try {
var stat = fs.statSync(folderPath);
return stat && stat.isDirectory();
} catch (e) {
return false;
}
}
// Construct a default value for CFBundleVersion as the version with any
// -rclabel stripped=.
function default_CFBundleVersion (version) {
return version.split('-')[0];
}
// Converts cordova specific representation of target device to XCode value
function parseTargetDevicePreference (value) {
if (!value) return null;
var map = { 'universal': '"1,2"', 'handset': '"1"', 'tablet': '"2"' };
if (map[value.toLowerCase()]) {
return map[value.toLowerCase()];
}
events.emit('warn', 'Unrecognized value for target-device preference: ' + value + '.');
return null;
}