Files
meteor/tools/cordova/builder.js
2016-06-29 09:18:12 +10:00

654 lines
22 KiB
JavaScript

import _ from 'underscore';
import util from 'util';
import { Console } from '../console/console.js';
import buildmessage from '../utils/buildmessage.js';
import files from '../fs/files.js';
import bundler from '../isobuild/bundler.js';
import archinfo from '../utils/archinfo.js';
import release from '../packaging/release.js';
import { load as loadIsopacket } from '../tool-env/isopackets.js';
import utils from '../utils/utils.js';
import { CORDOVA_ARCH } from './index.js';
// Hard-coded size constants
const iconsIosSizes = {
'iphone_2x': '120x120',
'iphone_3x': '180x180',
'ipad': '76x76',
'ipad_2x': '152x152',
'ipad_pro': '167x167',
'ios_settings': '29x29',
'ios_settings_2x': '58x58',
'ios_settings_3x': '87x87',
'ios_spotlight': '40x40',
'ios_spotlight_2x': '80x80'
};
const iconsAndroidSizes = {
'android_mdpi': '48x48',
'android_hdpi': '72x72',
'android_xhdpi': '96x96',
'android_xxhdpi': '144x144',
'android_xxxhdpi': '192x192'
};
const launchIosSizes = {
'iphone_2x': '640x960',
'iphone5': '640x1136',
'iphone6': '750x1334',
'iphone6p_portrait': '1242x2208',
'iphone6p_landscape': '2208x1242',
'ipad_portrait': '768x1024',
'ipad_portrait_2x': '1536x2048',
'ipad_landscape': '1024x768',
'ipad_landscape_2x': '2048x1536'
};
const launchAndroidSizes = {
'android_mdpi_portrait': '320x470',
'android_mdpi_landscape': '470x320',
'android_hdpi_portrait': '480x640',
'android_hdpi_landscape': '640x480',
'android_xhdpi_portrait': '720x960',
'android_xhdpi_landscape': '960x720',
'android_xxhdpi_portrait': '1080x1440',
'android_xxhdpi_landscape': '1440x1080'
};
export class CordovaBuilder {
constructor(projectContext, projectRoot, options) {
this.projectContext = projectContext;
this.projectRoot = projectRoot;
this.options = options;
this.resourcesPath = files.pathJoin(
this.projectRoot,
'resources');
this.initalizeDefaults();
}
initalizeDefaults() {
// Convert the appId (a base 36 string) to a number
const appIdAsNumber = parseInt(this.projectContext.appIdentifier, 36);
// We use the appId to choose a local server port between 12000-13000.
// This range should be large enough to avoid collisions with other
// Meteor apps, and has also been chosen to avoid collisions
// with other apps or services on the device (although this can never be
// guaranteed).
const localServerPort = 12000 + (appIdAsNumber % 1000);
this.metadata = {
id: 'com.id' + this.projectContext.appIdentifier,
version: '0.0.1',
buildNumber: undefined,
name: files.pathBasename(this.projectContext.projectDir),
description: 'New Meteor Mobile App',
author: 'A Meteor Developer',
email: 'n/a',
website: 'n/a',
contentUrl: `http://localhost:${localServerPort}/`
};
// Set some defaults different from the Cordova defaults
this.additionalConfiguration = {
global: {
'webviewbounce': false,
'DisallowOverscroll': true
},
platform: {
ios: {},
android: {}
}
};
// Custom elements that will be appended into config.xml's widgets
this.custom = [];
const packageMap = this.projectContext.packageMap;
if (packageMap && packageMap.getInfo('launch-screen')) {
this.additionalConfiguration.global.AutoHideSplashScreen = false;
this.additionalConfiguration.global.SplashScreen = 'screen';
this.additionalConfiguration.global.SplashScreenDelay = 5000;
this.additionalConfiguration.global.FadeSplashScreenDuration = 250;
this.additionalConfiguration.global.ShowSplashScreenSpinner = false;
}
if (packageMap && packageMap.getInfo('mobile-status-bar')) {
this.additionalConfiguration.global.StatusBarOverlaysWebView = false;
this.additionalConfiguration.global.StatusBarStyle = 'default';
}
// Default access rules.
// Rules can be extended with App.accesRule() in mobile-config.js.
this.accessRules = {
// Allow the app to ask the system to open these types of URLs.
// (e.g. in the phone app or an email client)
'tel:*': { type: 'intent' },
'geo:*': { type: 'intent' },
'mailto:*': { type: 'intent' },
'sms:*': { type: 'intent' },
'market:*': { type: 'intent' },
'itms:*': { type: 'intent' },
'itms-apps:*': { type: 'intent' },
// Allow navigation to localhost, which is needed for the local server
'http://localhost': { type: 'navigation' }
};
const mobileServerUrl = this.options.mobileServerUrl;
const serverDomain = mobileServerUrl ?
utils.parseUrl(mobileServerUrl).hostname : null;
// If the remote server domain is known, allow access to it for XHR and DDP
// connections.
if (serverDomain) {
// Application Transport Security (new in iOS 9) doesn't allow you
// to give access to IP addresses (just domains). So we allow access to
// everything if we don't have a domain, which sets NSAllowsArbitraryLoads.
if (utils.isIPv4Address(serverDomain)) {
this.accessRules['*'] = { type: 'network' };
} else {
this.accessRules['*://' + serverDomain] = { type: 'network' };
// Android talks to localhost over 10.0.2.2. This config file is used for
// multiple platforms, so any time that we say the server is on localhost we
// should also say it is on 10.0.2.2.
if (serverDomain === 'localhost') {
this.accessRules['*://10.0.2.2'] = { type: 'network' };
}
}
}
this.imagePaths = {
icon: {},
splash: {}
};
// Defaults are Meteor meatball images located in tools/cordova/assets directory
const assetsPath = files.pathJoin(__dirname, 'assets');
const iconsPath = files.pathJoin(assetsPath, 'icons');
const launchScreensPath = files.pathJoin(assetsPath, 'launchscreens');
const setDefaultIcon = (size, name) => {
const imageFile = files.pathJoin(iconsPath, size + '.png');
if (files.exists(imageFile)) {
this.imagePaths.icon[name] = imageFile;
}
};
const setDefaultLaunchScreen = (size, name) => {
const imageFile = files.pathJoin(launchScreensPath, `${size}.png`);
if (files.exists(imageFile)) {
this.imagePaths.splash[name] = imageFile;
}
};
_.each(iconsIosSizes, setDefaultIcon);
_.each(iconsAndroidSizes, setDefaultIcon);
_.each(launchIosSizes, setDefaultLaunchScreen);
_.each(launchAndroidSizes, setDefaultLaunchScreen);
this.pluginsConfiguration = {};
}
processControlFile() {
const controlFilePath =
files.pathJoin(this.projectContext.projectDir, 'mobile-config.js');
if (files.exists(controlFilePath)) {
Console.debug('Processing mobile-config.js');
buildmessage.enterJob({ title: `processing mobile-config.js` }, () => {
const code = files.readFile(controlFilePath, 'utf8');
try {
files.runJavaScript(code, {
filename: 'mobile-config.js',
symbols: { App: createAppConfiguration(this) }
});
} catch (error) {
buildmessage.exception(error);
}
});
}
}
writeConfigXmlAndCopyResources(shouldCopyResources = true) {
const { XmlBuilder } = loadIsopacket('cordova-support')['xmlbuilder'];
let config = XmlBuilder.create('widget');
// Set the root attributes
_.each({
id: this.metadata.id,
version: this.metadata.version,
'android-versionCode': this.metadata.buildNumber,
'ios-CFBundleVersion': this.metadata.buildNumber,
xmlns: 'http://www.w3.org/ns/widgets',
'xmlns:cdv': 'http://cordova.apache.org/ns/1.0'
}, (value, key) => {
if (value) {
config.att(key, value);
}
});
// Set the metadata
config.element('name').txt(this.metadata.name);
config.element('description').txt(this.metadata.description);
config.element('author', {
href: this.metadata.website,
email: this.metadata.email
}).txt(this.metadata.author);
// Set the additional global configuration preferences
_.each(this.additionalConfiguration.global, (value, key) => {
config.element('preference', {
name: key,
value: value.toString()
});
});
// Set custom tags into widget element
_.each(this.custom, elementSet => {
const tag = config.raw(elementSet);
});
config.element('content', { src: this.metadata.contentUrl });
// Copy all the access rules
_.each(this.accessRules, (options, pattern) => {
const type = options.type;
options = _.omit(options, 'type');
if (type === 'intent') {
config.element('allow-intent', { href: pattern });
} else if (type === 'navigation') {
config.element('allow-navigation', _.extend({ href: pattern }, options));
} else {
config.element('access', _.extend({ origin: pattern }, options));
}
});
const platformElement = {
ios: config.element('platform', {name: 'ios'}),
android: config.element('platform', {name: 'android'})
}
// Set the additional platform-specific configuration preferences
_.each(this.additionalConfiguration.platform, (prefs, platform) => {
_.each(prefs, (value, key) => {
platformElement[platform].element('preference', {
name: key,
value: value.toString()
});
});
});
if (shouldCopyResources) {
// Prepare the resources folder
files.rm_recursive(this.resourcesPath);
files.mkdir_p(this.resourcesPath);
Console.debug('Copying resources for mobile apps');
this.configureAndCopyImages(iconsIosSizes, platformElement.ios, 'icon');
this.configureAndCopyImages(iconsAndroidSizes, platformElement.android, 'icon');
this.configureAndCopyImages(launchIosSizes, platformElement.ios, 'splash');
this.configureAndCopyImages(launchAndroidSizes, platformElement.android, 'splash');
}
Console.debug('Writing new config.xml');
const configXmlPath = files.pathJoin(this.projectRoot, 'config.xml');
const formattedXmlConfig = config.end({ pretty: true });
files.writeFile(configXmlPath, formattedXmlConfig, 'utf8');
}
configureAndCopyImages(sizes, xmlElement, tag) {
const imageAttributes = (name, width, height, src) => {
const androidMatch = /android_(.?.dpi)_(landscape|portrait)/g.exec(name);
let attributes = {
src: src,
width: width,
height: height
};
// XXX special case for Android
if (androidMatch) {
attributes.density =
androidMatch[2].substr(0, 4) + '-' + androidMatch[1];
}
return attributes;
};
_.each(sizes, (size, name) => {
const [width, height] = size.split('x');
const suppliedPath = this.imagePaths[tag][name];
if (!suppliedPath) {
return;
}
const suppliedFilename = _.last(suppliedPath.split(files.pathSep));
let extension = _.last(suppliedFilename.split('.'));
// XXX special case for 9-patch png's
if (suppliedFilename.match(/\.9\.png$/)) {
extension = '9.png';
}
const filename = name + '.' + tag + '.' + extension;
const src = files.pathJoin('resources', filename);
// Copy the file to the build folder with a standardized name
files.copyFile(
files.pathResolve(this.projectContext.projectDir, suppliedPath),
files.pathJoin(this.resourcesPath, filename));
// Set it to the xml tree
xmlElement.element(tag, imageAttributes(name, width, height, src));
});
}
copyWWW(bundlePath) {
const wwwPath = files.pathJoin(this.projectRoot, 'www');
// Remove existing www
files.rm_recursive(wwwPath);
// Create www and www/application directories
const applicationPath = files.pathJoin(wwwPath, 'application');
files.mkdir_p(applicationPath);
// Copy Cordova arch program from bundle to www/application
const programPath = files.pathJoin(bundlePath, 'programs', CORDOVA_ARCH);
files.cp_r(programPath, applicationPath);
// Load program.json
const programJsonPath = files.convertToOSPath(
files.pathJoin(applicationPath, 'program.json'));
const program = JSON.parse(files.readFile(programJsonPath, 'utf8'));
// Load settings
const settingsFile = this.options.settingsFile;
const settings = settingsFile ?
JSON.parse(files.readFile(settingsFile, 'utf8')) : {};
const publicSettings = settings['public'];
// Calculate client hash and append to program
this.appendVersion(program, publicSettings);
// Write program.json
files.writeFile(programJsonPath, JSON.stringify(program), 'utf8');
const bootstrapPage = this.generateBootstrapPage(applicationPath, program, publicSettings);
files.writeFile(files.pathJoin(applicationPath, 'index.html'),
bootstrapPage, 'utf8');
}
appendVersion(program, publicSettings) {
let configDummy = {};
configDummy.PUBLIC_SETTINGS = publicSettings || {};
const { WebAppHashing } =
loadIsopacket('cordova-support')['webapp-hashing'];
program.version =
WebAppHashing.calculateClientHash(program.manifest, null, configDummy);
}
generateBootstrapPage(applicationPath, program, publicSettings) {
const meteorRelease =
release.current.isCheckout() ? "none" : release.current.name;
const manifest = program.manifest;
const autoupdateVersion = process.env.AUTOUPDATE_VERSION || program.version;
const mobileServerUrl = this.options.mobileServerUrl;
const runtimeConfig = {
meteorRelease: meteorRelease,
ROOT_URL: mobileServerUrl,
// XXX propagate it from this.options?
ROOT_URL_PATH_PREFIX: '',
DDP_DEFAULT_CONNECTION_URL: mobileServerUrl,
autoupdateVersionCordova: autoupdateVersion,
appId: this.projectContext.appIdentifier,
meteorEnv: {
NODE_ENV: process.env.NODE_ENV || "production",
TEST_METADATA: process.env.TEST_METADATA || "{}"
}
};
if (publicSettings) {
runtimeConfig.PUBLIC_SETTINGS = publicSettings;
}
const { Boilerplate } =
loadIsopacket('cordova-support')['boilerplate-generator'];
const boilerplate = new Boilerplate(CORDOVA_ARCH, manifest, {
urlMapper: _.identity,
pathMapper: (path) => files.convertToOSPath(
files.pathJoin(applicationPath, path)),
baseDataExtension: {
meteorRuntimeConfig: JSON.stringify(
encodeURIComponent(JSON.stringify(runtimeConfig)))
}
});
return boilerplate.toHTML();
}
copyBuildOverride() {
const buildOverridePath =
files.pathJoin(this.projectContext.projectDir, 'cordova-build-override');
if (files.exists(buildOverridePath) &&
files.stat(buildOverridePath).isDirectory()) {
Console.debug('Copying over the cordova-build-override directory');
files.cp_r(buildOverridePath, this.projectRoot);
}
}
}
function createAppConfiguration(builder) {
/**
* @namespace App
* @global
* @summary The App configuration object in mobile-config.js
*/
return {
/**
* @summary Set your mobile app's core configuration information.
* @param {Object} options
* @param {String} [options.id,version,name,description,author,email,website]
* Each of the options correspond to a key in the app's core configuration
* as described in the [Cordova documentation](http://cordova.apache.org/docs/en/5.1.1/config_ref_index.md.html#The%20config.xml%20File_core_configuration_elements).
* @memberOf App
*/
info: function (options) {
// check that every key is meaningful
_.each(options, function (value, key) {
if (!_.has(builder.metadata, key)) {
throw new Error("Unknown key in App.info configuration: " + key);
}
});
_.extend(builder.metadata, options);
},
/**
* @summary Add a preference for your build as described in the
* [Cordova documentation](http://cordova.apache.org/docs/en/5.1.1/config_ref_index.md.html#The%20config.xml%20File_global_preferences).
* @param {String} name A preference name supported by Cordova's
* `config.xml`.
* @param {String} value The value for that preference.
* @param {String} [platform] Optional. A platform name (either `ios` or `android`) to add a platform-specific preference.
* @memberOf App
*/
setPreference: function (key, value, platform) {
if (platform) {
if (!_.contains(['ios', 'android'], platform)) {
throw new Error(`Unknown platform in App.setPreference: ${platform}. \
Valid platforms are: ios, android.`);
}
builder.additionalConfiguration.platform[platform][key] = value;
} else {
builder.additionalConfiguration.global[key] = value;
}
},
/**
* @summary Set the build-time configuration for a Cordova plugin.
* @param {String} id The identifier of the plugin you want to
* configure.
* @param {Object} config A set of key-value pairs which will be passed
* at build-time to configure the specified plugin.
* @memberOf App
*/
configurePlugin: function (id, config) {
builder.pluginsConfiguration[id] = config;
},
/**
* @summary Set the icons for your mobile app.
* @param {Object} icons An Object where the keys are different
* devices and screen sizes, and values are image paths
* relative to the project root directory.
*
* Valid key values:
* - `iphone_2x` (120x120)
* - `iphone_3x` (180x180)
* - `ipad` (76x76)
* - `ipad_2x` (152x152)
* - `ipad_pro` (167x167)
* - `ios_settings` (29x29)
* - `ios_settings_2x` (58x58)
* - `ios_settings_3x` (87x87)
* - `ios_spotlight` (40x40)
* - `ios_spotlight_2x` (80x80)
* - `android_mdpi` (48x48)
* - `android_hdpi` (72x72)
* - `android_xhdpi` (96x96)
* - `android_xxhdpi` (144x144)
* - `android_xxxhdpi` (192x192)
* @memberOf App
*/
icons: function (icons) {
var validDevices =
_.keys(iconsIosSizes).concat(_.keys(iconsAndroidSizes));
_.each(icons, function (value, key) {
if (!_.include(validDevices, key)) {
Console.labelWarn(`${key}: unknown key in App.icons \
configuration. The key may be deprecated.`);
}
});
_.extend(builder.imagePaths.icon, icons);
},
/**
* @summary Set the launch screen images for your mobile app.
* @param {Object} launchScreens A dictionary where keys are different
* devices, screen sizes, and orientations, and the values are image paths
* relative to the project root directory.
*
* For Android, launch screen images should
* be special "Nine-patch" image files that specify how they should be
* stretched. See the [Android docs](https://developer.android.com/guide/topics/graphics/2d-graphics.html#nine-patch).
*
* Valid key values:
* - `iphone_2x` (640x960)
* - `iphone5` (640x1136)
* - `iphone6` (750x1334)
* - `iphone6p_portrait` (1242x2208)
* - `iphone6p_landscape` (2208x1242)
* - `ipad_portrait` (768x1024)
* - `ipad_portrait_2x` (1536x2048)
* - `ipad_landscape` (1024x768)
* - `ipad_landscape_2x` (2048x1536)
* - `android_mdpi_portrait` (320x470)
* - `android_mdpi_landscape` (470x320)
* - `android_hdpi_portrait` (480x640)
* - `android_hdpi_landscape` (640x480)
* - `android_xhdpi_portrait` (720x960)
* - `android_xhdpi_landscape` (960x720)
* - `android_xxhdpi_portrait` (1080x1440)
* - `android_xxhdpi_landscape` (1440x1080)
*
* @memberOf App
*/
launchScreens: function (launchScreens) {
var validDevices =
_.keys(launchIosSizes).concat(_.keys(launchAndroidSizes));
_.each(launchScreens, function (value, key) {
if (!_.include(validDevices, key)) {
Console.labelWarn(`${key}: unknown key in App.launchScreens \
configuration. The key may be deprecated.`);
}
});
_.extend(builder.imagePaths.splash, launchScreens);
},
/**
* @summary Set a new access rule based on origin domain for your app.
* By default your application has a limited list of servers it can contact.
* Use this method to extend this list.
*
* Default access rules:
*
* - `tel:*`, `geo:*`, `mailto:*`, `sms:*`, `market:*` are allowed and
* are handled by the system (e.g. opened in the phone app or an email client)
* - `http://localhost:*` is used to serve the app's assets from.
* - The domain or address of the Meteor server to connect to for DDP and
* hot code push of new versions.
*
* Read more about domain patterns in [Cordova
* docs](http://cordova.apache.org/docs/en/6.0.0/guide_appdev_whitelist_index.md.html).
*
* Starting with Meteor 1.0.4 access rule for all domains and protocols
* (`<access origin="*"/>`) is no longer set by default due to
* [certain kind of possible
* attacks](http://cordova.apache.org/announcements/2014/08/04/android-351.html).
*
* @param {String} pattern The pattern defining affected domains or URLs.
* @param {Object} [options]
* @param {String} options.type Possible values:
* - **`'intent'`**: Controls which URLs the app is allowed to ask the system to open.
* (e.g. in the phone app or an email client).
* - **`'navigation'`**: Controls which URLs the WebView itself can be navigated to
* (can also needed for iframes).
* - **`'network'` or undefined**: Controls which network requests (images, XHRs, etc) are allowed to be made.
* @param {Boolean} options.launchExternal (Deprecated, use `type: 'intent'` instead.)
* @memberOf App
*/
accessRule: function (pattern, options) {
options = options || {};
if (options.launchExternal) {
options.type = 'intent';
}
builder.accessRules[pattern] = options;
},
/**
* @summary Append custom tags into config's widget element.
*
* `App.appendToConfig('<any-xml-content/>');`
*
* @param {String} element The XML you want to include
* @memberOf App
*/
appendToConfig: function (xml) {
builder.custom.push(xml);
},
};
}