-
-
Save marcocarnazzo/6f01a57d390e8fe3071f to your computer and use it in GitHub Desktop.
#!/usr/bin/env node | |
/** This hook updates platform configuration files based on preferences and config-file data defined in config.xml. | |
Currently only the AndroidManifest.xml and IOS *-Info.plist file are supported. | |
See http://stackoverflow.com/questions/28198983/ionic-cordova-add-intent-filter-using-config-xml | |
Preferences: | |
1. Preferences defined outside of the platform element will apply to all platforms | |
2. Preferences defined inside a platform element will apply only to the specified platform | |
3. Platform preferences take precedence over common preferences | |
4. The preferenceMappingData object contains all of the possible custom preferences to date including the | |
target file they belong to, parent element, and destination element or attribute | |
Config Files | |
1. config-file elements MUST be defined inside a platform element, otherwise they will be ignored. | |
2. config-file target attributes specify the target file to update. (AndroidManifest.xml or *-Info.plist) | |
3. config-file parent attributes specify the parent element (AndroidManifest.xml) or parent key (*-Info.plist) | |
that the child data will replace or be appended to. | |
4. config-file elements are uniquely indexed by target AND parent for each platform. | |
5. If there are multiple config-file's defined with the same target AND parent, the last config-file will be used | |
6. Elements defined WITHIN a config-file will replace or be appended to the same elements relative to the parent element | |
7. If a unique config-file contains multiples of the same elements (other than uses-permission elements which are | |
selected by by the uses-permission name attribute), the last defined element will be retrieved. | |
Examples: | |
AndroidManifest.xml | |
NOTE: For possible manifest values see http://developer.android.com/guide/topics/manifest/manifest-intro.html | |
<platform name="android"> | |
//These preferences are actually available in Cordova by default although not currently documented | |
<preference name="android-minSdkVersion" value="8" /> | |
<preference name="android-maxSdkVersion" value="19" /> | |
<preference name="android-targetSdkVersion" value="19" /> | |
//custom preferences examples | |
<preference name="android-windowSoftInputMode" value="stateVisible" /> | |
<preference name="android-installLocation" value="auto" /> | |
<preference name="android-launchMode" value="singleTop" /> | |
<preference name="android-activity-hardwareAccelerated" value="false" /> | |
<preference name="android-manifest-hardwareAccelerated" value="false" /> | |
<preference name="android-configChanges" value="orientation" /> | |
<preference name="android-theme" value="@android:style/Theme.Black.NoTitleBar" /> | |
<config-file target="AndroidManifest.xml" parent="/*> | |
<supports-screens | |
android:xlargeScreens="false" | |
android:largeScreens="false" | |
android:smallScreens="false" /> | |
<uses-permission android:name="android.permission.READ_CONTACTS" android:maxSdkVersion="15" /> | |
<uses-permission android:name="android.permission.WRITE_CONTACTS" /> | |
</config-file> | |
</platform> | |
*-Info.plist | |
<platform name="ios"> | |
<config-file platform="ios" target="*-Info.plist" parent="UISupportedInterfaceOrientations"> | |
<array> | |
<string>UIInterfaceOrientationLandscapeOmg</string> | |
</array> | |
</config-file> | |
<config-file platform="ios" target="*-Info.plist" parent="SomeOtherPlistKey"> | |
<string>someValue</string> | |
</config-file> | |
</platform> | |
NOTE: Currently, items aren't removed from the platform config files if you remove them from config.xml. | |
For example, if you add a custom permission, build the remove it, it will still be in the manifest. | |
If you make a mistake, for example adding an element to the wrong parent, you may need to remove and add your platform, | |
or revert to your previous manifest/plist file. | |
TODO: We may need to capture all default manifest/plist elements/keys created by Cordova along with any plugin elements/keys to compare against custom elements to remove. | |
== ABOUT THIS CODE == | |
Original code was written by Devin Jett ( https://github.com/djett41 ) | |
and then modified by Marco Carnazzo ( https://github.com/marcocarnazzo ). | |
This hook is in public domain. | |
*/ | |
// global vars | |
var fs = require('fs'); | |
var path = require('path'); | |
var _ = require('lodash'); | |
var et = require('elementtree'); | |
var plist = require('plist'); | |
var rootdir = path.resolve(__dirname, '../../'); | |
var platformConfig = (function(){ | |
/* Global object that defines the available custom preferences for each platform. | |
Maps a config.xml preference to a specific target file, parent element, and destination attribute or element | |
*/ | |
var preferenceMappingData = { | |
'android': { | |
'android-manifest-hardwareAccelerated': {target: 'AndroidManifest.xml', parent: './', destination: 'android:hardwareAccelerated'}, | |
'android-installLocation': {target: 'AndroidManifest.xml', parent: './', destination: 'android:installLocation'}, | |
'android-activity-hardwareAccelerated': {target: 'AndroidManifest.xml', parent: 'application', destination: 'android:hardwareAccelerated'}, | |
'android-configChanges': {target: 'AndroidManifest.xml', parent: 'application/activity[@android:name=\'CordovaApp\']', destination: 'android:configChanges'}, | |
'android-launchMode': {target: 'AndroidManifest.xml', parent: 'application/activity[@android:name=\'CordovaApp\']', destination: 'android:launchMode'}, | |
'android-theme': {target: 'AndroidManifest.xml', parent: 'application/activity[@android:name=\'CordovaApp\']', destination: 'android:theme'}, | |
'android-windowSoftInputMode': {target: 'AndroidManifest.xml', parent: 'application/activity[@android:name=\'CordovaApp\']', destination: 'android:windowSoftInputMode'} | |
}, | |
'ios': {} | |
}; | |
/* Global object that defines tags that should be added and not replaced | |
*/ | |
var multipleTags = { | |
'android': ['intent-filter'], | |
'ios': [] | |
}; | |
var configXmlData, preferencesData; | |
return { | |
// Parses a given file into an elementtree object | |
parseElementtreeSync: function (filename) { | |
var contents = fs.readFileSync(filename, 'utf-8'); | |
if(contents) { | |
//Windows is the BOM. Skip the Byte Order Mark. | |
contents = contents.substring(contents.indexOf('<')); | |
} | |
return new et.ElementTree(et.XML(contents)); | |
}, | |
// Converts an elementtree object to an xml string. Since this is used for plist values, we don't care about attributes | |
eltreeToXmlString: function (data) { | |
var tag = data.tag; | |
var el = '<' + tag + '>'; | |
if(data.text && data.text.trim()) { | |
el += data.text.trim(); | |
} else { | |
_.each(data.getchildren(), function (child) { | |
el += platformConfig.eltreeToXmlString(child); | |
}); | |
} | |
el += '</' + tag + '>'; | |
return el; | |
}, | |
// Parses the config.xml into an elementtree object and stores in the config object | |
getConfigXml: function () { | |
if(!configXmlData) { | |
configXmlData = this.parseElementtreeSync(path.join(rootdir, 'config.xml')); | |
} | |
return configXmlData; | |
}, | |
/* Retrieves all <preferences ..> from config.xml and returns a map of preferences with platform as the key. | |
If a platform is supplied, common prefs + platform prefs will be returned, otherwise just common prefs are returned. | |
*/ | |
getPreferences: function (platform) { | |
var configXml = this.getConfigXml(); | |
//init common config.xml prefs if we haven't already | |
if(!preferencesData) { | |
preferencesData = { | |
common: configXml.findall('preference') | |
}; | |
} | |
var prefs = preferencesData.common || []; | |
if(platform) { | |
if(!preferencesData[platform]) { | |
preferencesData[platform] = configXml.findall('platform[@name=\'' + platform + '\']/preference'); | |
} | |
prefs = prefs.concat(preferencesData[platform]); | |
} | |
return prefs; | |
}, | |
/* Retrieves all configured xml for a specific platform/target/parent element nested inside a platforms config-file | |
element within the config.xml. The config-file elements are then indexed by target|parent so if there are | |
any config-file elements per platform that have the same target and parent, the last config-file element is used. | |
*/ | |
getConfigFilesByTargetAndParent: function (platform) { | |
var configFileData = this.getConfigXml().findall('platform[@name=\'' + platform + '\']/config-file'); | |
return _.keyBy(configFileData, function(item) { | |
var parent = item.attrib.parent; | |
//if parent attribute is undefined /* or */, set parent to top level elementree selector | |
if(!parent || parent === '/*' || parent === '*/') { | |
parent = './'; | |
} | |
return item.attrib.target + '|' + parent; | |
}); | |
}, | |
/** | |
* Check if a tag can be used multiple times in config | |
*/ | |
isMultipleTag: function(platform, tag) { | |
var platformMultipleTags = multipleTags[platform]; | |
if (platformMultipleTags) { | |
var isInArray = (platformMultipleTags.indexOf(tag) >= 0); | |
return isInArray; | |
} else { | |
return false; | |
} | |
}, | |
// Parses the config.xml's preferences and config-file elements for a given platform | |
parseConfigXml: function (platform) { | |
var configData = {}; | |
this.parsePreferences(configData, platform); | |
this.parseConfigFiles(configData, platform); | |
return configData; | |
}, | |
// Retrieves th e config.xml's pereferences for a given platform and parses them into JSON data | |
parsePreferences: function (configData, platform) { | |
var preferences = this.getPreferences(platform), | |
type = 'preference'; | |
_.each(preferences, function (preference) { | |
var prefMappingData = preferenceMappingData[platform][preference.attrib.name], | |
target, | |
prefData; | |
if (prefMappingData) { | |
prefData = { | |
parent: prefMappingData.parent, | |
type: type, | |
destination: prefMappingData.destination, | |
data: preference | |
}; | |
target = prefMappingData.target; | |
if(!configData[target]) { | |
configData[target] = []; | |
} | |
configData[target].push(prefData); | |
} | |
}); | |
}, | |
// Retrieves the config.xml's config-file elements for a given platform and parses them into JSON data | |
parseConfigFiles: function (configData, platform) { | |
var configFiles = this.getConfigFilesByTargetAndParent(platform), | |
type = 'configFile'; | |
_.each(configFiles, function (configFile, key) { | |
var keyParts = key.split('|'); | |
var target = keyParts[0]; | |
var parent = keyParts[1]; | |
var items = configData[target] || []; | |
_.each(configFile.getchildren(), function (element) { | |
items.push({ | |
parent: parent, | |
type: type, | |
destination: element.tag, | |
data: element | |
}); | |
}); | |
configData[target] = items; | |
}); | |
}, | |
// Parses config.xml data, and update each target file for a specified platform | |
updatePlatformConfig: function (platform) { | |
var configData = this.parseConfigXml(platform), | |
platformPath = path.join(rootdir, 'platforms', platform); | |
_.each(configData, function (configItems, targetFileName) { | |
var projectName, targetFile; | |
if (platform === 'ios' && targetFileName.indexOf("Info.plist") > -1) { | |
projectName = platformConfig.getConfigXml().findtext('name'); | |
targetFile = path.join(platformPath, projectName, projectName + '-Info.plist'); | |
platformConfig.updateIosPlist(targetFile, configItems); | |
} else if (platform === 'android' && targetFileName === 'AndroidManifest.xml') { | |
targetFile = path.join(platformPath, targetFileName); | |
platformConfig.updateAndroidManifest(targetFile, configItems); | |
} | |
}); | |
}, | |
// Updates the AndroidManifest.xml target file with data from config.xml | |
updateAndroidManifest: function (targetFile, configItems) { | |
var tempManifest = platformConfig.parseElementtreeSync(targetFile), | |
root = tempManifest.getroot(); | |
_.each(configItems, function (item) { | |
// if parent is not found on the root, child/grandchild nodes are searched | |
var parentEl = root.find(item.parent) || root.find('*/' + item.parent), | |
data = item.data, | |
childSelector = item.destination, | |
childEl; | |
if(!parentEl) { | |
return; | |
} | |
if(item.type === 'preference') { | |
parentEl.attrib[childSelector] = data.attrib['value']; | |
} else { | |
// since there can be multiple uses-permission elements, we need to select them by unique name | |
if(childSelector === 'uses-permission') { | |
childSelector += '[@android:name=\'' + data.attrib['android:name'] + '\']'; | |
} | |
childEl = parentEl.find(childSelector); | |
// if child element doesnt exist, create new element | |
var isMultipleTag = platformConfig.isMultipleTag('android', childSelector); | |
if(!childEl || isMultipleTag) { | |
childEl = new et.Element(item.destination); | |
parentEl.append(childEl); | |
} | |
// copy all config.xml data except for the generated _id property | |
_.each(data, function (prop, propName) { | |
if(propName !== '_id') { | |
childEl[propName] = prop; | |
} | |
}); | |
} | |
}); | |
fs.writeFileSync(targetFile, tempManifest.write({indent: 4}), 'utf-8'); | |
}, | |
/* Updates the *-Info.plist file with data from config.xml by parsing to an xml string, then using the plist | |
module to convert the data to a map. The config.xml data is then replaced or appended to the original plist file | |
*/ | |
updateIosPlist: function (targetFile, configItems) { | |
var infoPlist = plist.parse(fs.readFileSync(targetFile, 'utf-8')), | |
tempInfoPlist; | |
_.each(configItems, function (item) { | |
var key = item.parent; | |
var plistXml = '<plist><dict><key>' + key + '</key>'; | |
plistXml += platformConfig.eltreeToXmlString(item.data) + '</dict></plist>'; | |
var configPlistObj = plist.parse(plistXml); | |
infoPlist[key] = configPlistObj[key]; | |
}); | |
tempInfoPlist = plist.build(infoPlist); | |
tempInfoPlist = tempInfoPlist.replace(/<string>[\s\r\n]*<\/string>/g,'<string></string>'); | |
fs.writeFileSync(targetFile, tempInfoPlist, 'utf-8'); | |
} | |
}; | |
})(); | |
// Main | |
(function () { | |
if (rootdir) { | |
// go through each of the platform directories that have been prepared | |
var platforms = _.filter(fs.readdirSync('platforms'), function (file) { | |
return fs.statSync(path.resolve('platforms', file)).isDirectory(); | |
}); | |
_.each(platforms, function (platform) { | |
try { | |
platform = platform.trim().toLowerCase(); | |
platformConfig.updatePlatformConfig(platform); | |
} catch (e) { | |
process.stdout.write(e); | |
} | |
}); | |
} | |
})(); |
This is not working in Xcode8 + iOS10.
I am unable to find the exact bug, but the app doesn't start if this script is running and changing the plist file.
If I remove this script the app runs fine.
Also, the custom config that I am trying to add with this script, if I add the exact same value manually in the plist file via editor - the app works just fine.
I suspect something is wrong with respect to the Xcode8 !
@sur I think I may have the same issue. However it's not xCode, but how the script transforms the -info.plist file. For me it seems to be converting
<key>NSMainNibFile</key> <string/> <key>NSMainNibFile~ipad</key> <string/>
to
<key>NSMainNibFile</key> <string>NSMainNibFile~ipad</string>
Which crashed the app when you run it on the device. The issue is with plist@2.0.* (see issue: TooTallNate/plist.js#79). Workaround: Just roll back to use plist@1.2.0.
@halyburton Thanks for the hint! There seems to be something wrong with parsing ~
characters, but I got no idea where that comes from. In the end I just wrote a script to add my plist stuff to the platforms/ios/<project>/<project>-Info.plist
file.
This hook helped me a lot though!
@marcocarnazzo this hook was very helpful, thank you! As others have noted I needed to change _.indexBy
to _.keyBy
in the getConfigFilesByTargetAndParent
method. After that my plist is perfect, no more needing to edit manually!
+1 that this should be in repo so it can be edited and updated
I have changed the indexBy, added the element to the widget tag and removed entries in config.xml as suggested, but is still getting
net.js:655
throw new TypeError(
^
TypeError: Invalid data, chunk must be a string or buffer, not object
at Socket.write (net.js:655:11)
at D:\Dokumenter\GitHub\Geme\hooks\after_prepare\030_update_platform_config.js:366:32
at arrayEach (D:\Dokumenter\GitHub\Geme\node_modules\lodash\lodash.js:537:11)
at Function.forEach (D:\Dokumenter\GitHub\Geme\node_modules\lodash\lodash.js:9359:14)
at D:\Dokumenter\GitHub\Geme\hooks\after_prepare\030_update_platform_config.js:361:11
at Object. (D:\Dokumenter\GitHub\Geme\hooks\after_prepare\030_update_platform_config.js:370:3)
at Module._compile (module.js:570:32)
at Object.Module._extensions..js (module.js:579:10)
at Module.load (module.js:487:32)
at tryModuleLoad (module.js:446:12)
Error: Hook failed with error code 1: D:\Dokumenter\GitHub\Geme\hooks\after_prepare\030_update_platform_config.js
Any other ideas
@ThorvaldAagaard your error is related to @AgDude's comment about error reporting. You're not seeing the original error. Change the code as per @AgDude's instructions and you will see the actual error.
My AndroidManifest xml not being modified 😢
This works for me but only after I make these changes, and noting some things that are somehow implicitly assumed
- needed to change _.indexBy => _.keyBy
- looks like this script is expected to be placed in the same location as config.xml. if you place this script in a folder that is not the same as the config.xml file you need to update the rootdir location
- @AgDude's suggestion is required, otherwise it won't work at all, stdout.write(e.toString() + '\n');
I only do ios build for now so can't verify for Android yet
I'm so sorry!
GitHub does not notify by about your comments.
I just edited this hook as you suggest (thank you!).
I also write something about re-use of this code (public domain: use it as you like!).
Just a note: I'm not using Ionic anymore so this code is unmantained.
Sorry again.
_.indexBy has been replaced by _.keyBy in lodash by the way