diff options
Diffstat (limited to 'StoneIsland/platforms/ios/cordova/lib/prepare.js')
| -rwxr-xr-x | StoneIsland/platforms/ios/cordova/lib/prepare.js | 1003 |
1 files changed, 1003 insertions, 0 deletions
diff --git a/StoneIsland/platforms/ios/cordova/lib/prepare.js b/StoneIsland/platforms/ios/cordova/lib/prepare.js new file mode 100755 index 00000000..8d1cda94 --- /dev/null +++ b/StoneIsland/platforms/ios/cordova/lib/prepare.js @@ -0,0 +1,1003 @@ +/** + 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. +*/ + +var Q = require('q'); +var fs = require('fs'); +var path = require('path'); +var shell = require('shelljs'); +var xcode = require('xcode'); +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; + +/*jshint sub:true*/ + +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); + }) + .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); + }); +}; + +/** + * 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 pkg = platformConfig.getAttribute('ios-CFBundleIdentifier') || platformConfig.packageName(); + var version = platformConfig.version(); + + 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')); + infoPlist['CFBundleIdentifier'] = pkg; + + // 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'); + } + + // 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); + + var info_contents = plist.build(infoPlist); + 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 Identifier "' + pkg + '" and iOS Bundle Version "' + version + '" to ' + plistFile); + + return handleBuildSettings(platformConfig, locations).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) { + var targetDevice = parseTargetDevicePreference(platformConfig.getPreference('target-device', 'ios')); + var deploymentTarget = platformConfig.getPreference('deployment-target', 'ios'); + + // no build settings provided, we don't need to parse and update .pbxproj file + if (!targetDevice && !deploymentTarget) { + return Q(); + } + + var proj = new xcode.project(locations.pbxproj); + + try { + proj.parseSync(); + } catch (err) { + return Q.reject(new CordovaError('Could not parse project.pbxproj: ' + err)); + } + + if (targetDevice) { + events.emit('verbose', 'Set TARGETED_DEVICE_FAMILY to ' + targetDevice + '.'); + proj.updateBuildProperty('TARGETED_DEVICE_FAMILY', targetDevice); + } + + if (deploymentTarget) { + events.emit('verbose', 'Set IPHONEOS_DEPLOYMENT_TARGET to "' + deploymentTarget + '".'); + proj.updateBuildProperty('IPHONEOS_DEPLOYMENT_TARGET', deploymentTarget); + } + + fs.writeFileSync(locations.pbxproj, proj.writeSync(), 'utf-8'); + + 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-60@2x.png', width: 120, height: 120}, + {dest: 'icon-60@3x.png', width: 180, height: 180}, + {dest: 'icon-76.png', width: 76, height: 76}, + {dest: 'icon-76@2x.png', width: 152, height: 152}, + {dest: 'icon-small.png', width: 29, height: 29}, + {dest: 'icon-small@2x.png', width: 58, height: 58}, + {dest: 'icon-40.png', width: 40, height: 40}, + {dest: 'icon-40@2x.png', width: 80, height: 80}, + {dest: 'icon-small@3x.png', width: 87, height: 87}, + {dest: 'icon.png', width: 57, height: 57}, + {dest: 'icon@2x.png', width: 114, height: 114}, + {dest: 'icon-72.png', width: 72, height: 72}, + {dest: 'icon-72@2x.png', width: 144, height: 144}, + {dest: 'icon-50.png', width: 50, height: 50}, + {dest: 'icon-50@2x.png', width: 100, height: 100}, + {dest: 'icon-83.5@2x.png', width: 167, height: 167} + ]; + + 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} + ]; + + 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); + } +} + +/** + * 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 IMAGESET_COMPACT_SIZE_CLASS = 'compact'; + var CDV_ANY_SIZE_CLASS = 'any'; + + 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; +} + +/** + * 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 UI_LAUNCH_STORYBOARD_NAME = 'UILaunchStoryboardName'; + var CDV_LAUNCH_STORYBOARD_NAME = 'CDVLaunchScreen'; + + var splashScreens = platformConfig.getSplashScreens('ios'); + var contentsJSON = getLaunchStoryboardContentsJSON(splashScreens, ''); // note: we don't need a file path here; we're just counting + var currentLaunchStoryboard = infoPlist[UI_LAUNCH_STORYBOARD_NAME]; + + events.emit('verbose', 'Current launch storyboard ' + currentLaunchStoryboard); + + + /* 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: + * var hasLaunchStoryboardImages = !!contentsJSON.images.find(function (item) { + * return item.filename !== undefined; + * }); + */ + var hasLaunchStoryboardImages = !!contentsJSON.images.reduce(function (p, c) { + return (c.filename !== undefined) ? c : p; + }, undefined); + + 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 project 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 project to use launch images'); + infoPlist[UI_LAUNCH_STORYBOARD_NAME] = undefined; + return; + } + events.emit('verbose', 'Not changing launch storyboard setting.'); +} + +/** + * 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 = 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(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 = 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(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) + NSAllowsArbitraryLoadsInMedia, // 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_in_media : currentElement.allows_arbitrary_loads_in_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) + NSAllowsArbitraryLoadsInMedia, // boolean (default:false) + } + + null is returned if the URL cannot be parsed, or is to be skipped for ATS. +*/ +function parseWhitelistUrlForATS(url, options) { + var href = URL.parse(url); + 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_in_media === 'true'); + if (options.allows_arbitrary_loads_in_media && val) { // default is false + retObj.NSAllowsArbitraryLoadsInMedia = 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.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.NSAllowsArbitraryLoadsInMedia) { + ats['NSAllowsArbitraryLoadsInMedia'] = 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; +} |
