Skip to content

Instantly share code, notes, and snippets.

@marcocarnazzo
Last active May 31, 2019 15:31
Show Gist options
  • Star 40 You must be signed in to star a gist
  • Fork 14 You must be signed in to fork a gist
  • Save marcocarnazzo/6f01a57d390e8fe3071f to your computer and use it in GitHub Desktop.
Save marcocarnazzo/6f01a57d390e8fe3071f to your computer and use it in GitHub Desktop.
Ionic/Cordova update platform config
#!/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);
}
});
}
})();
@Justin-Credible
Copy link

This is a very useful hook! Thanks!

We needed a way to set a value in a plist that didn't have a parent (specifically UIRequiresFullScreen), but using the following XML (without a parent set)...

<config-file platform="ios" target="*-Info.plist">
    <dict>
        <key>UIRequiresFullScreen</key>
        <true/>
    </dict>
</config-file>

...results in the value being nested in a key named ./:

screen shot 2015-09-22 at 11 29 24 am

We made the following change to to the updateIosPlist which looks for the ./ key and merges the value to the root of the configuration instead:

if (item.parent === "./") {
    _.merge(infoPlist, configPlistObj["./"]);
}
else {
    infoPlist[key] = configPlistObj[key];
}

...which will properly set the property at the root level:

screen shot 2015-09-22 at 11 30 31 am

The change seems less than ideal, but I wasn't sure of the implications of changing the code in getConfigFilesByTargetAndParent that defaults the ./ value.

@Zorgatone
Copy link

@Justin-Credible commented on 22 set 2015, 20:44 CEST:

This is a very useful hook! Thanks!

We needed a way to set a value in a plist that didn't have a parent (specifically UIRequiresFullScreen), but using the following XML (without a parent set)...

[...]

...results in the value being nested in a key named ./:

screen shot 2015-09-22 at 11 29 24 am

We made the following change to to the updateIosPlist which looks for the ./ key and merges the value to the root of the configuration instead:

[...]

...which will properly set the property at the root level:

screen shot 2015-09-22 at 11 30 31 am

The change seems less than ideal, but I wasn't sure of the implications of changing the code in getConfigFilesByTargetAndParent that defaults the ./ value.

I believe you had to use this version, but somehow I can't seem to get it working...

<config-file parent="UIRequiresFullScreen" platform="ios" target="*-Info.plist">
  <true/>
</config-file>

@Zorgatone
Copy link

Do you have to use this before_build, before_compile or after_prepare? 😞

@Zorgatone
Copy link

I copied the file in the after_prepare directory and I got this error:

Running command: /Users/webdev1/Documents/bitron/B-See/hooks/after_prepare/030_update_platform_config.js /Users/webdev1/Documents/bitron/B-See
Error: spawn EACCES
    at exports._errnoException (util.js:874:11)
    at ChildProcess.spawn (internal/child_process.js:298:11)
    at Object.exports.spawn (child_process.js:339:9)
    at Object.exports.spawn (/usr/local/lib/node_modules/cordova/node_modules/cordova-lib/src/cordova/superspawn.js:108:31)
    at runScriptViaChildProcessSpawn (/usr/local/lib/node_modules/cordova/node_modules/cordova-lib/src/hooks/HooksRunner.js:189:23)
    at runScript (/usr/local/lib/node_modules/cordova/node_modules/cordova-lib/src/hooks/HooksRunner.js:132:16)
    at /usr/local/lib/node_modules/cordova/node_modules/cordova-lib/src/hooks/HooksRunner.js:115:20
    at _fulfilled (/usr/local/lib/node_modules/cordova/node_modules/q/q.js:787:54)
    at self.promiseDispatch.done (/usr/local/lib/node_modules/cordova/node_modules/q/q.js:816:30)
    at Promise.promise.promiseDispatch (/usr/local/lib/node_modules/cordova/node_modules/q/q.js:749:13)

@mvidailhet
Copy link

@Zorgatone you have to make the file executable to avoid this error

and you have to use it in after_prepare

@NickMele
Copy link

NickMele commented Nov 9, 2015

This was extremely useful! Thank you very much. I made a slight modification in my project to keep any other attribute values along with ones defined in the config.xml. I only made the modification for the android manifest but I would imagine it wouldn't be too difficult if needed for iOS.

I change line 321 from:

childEl[propName] = prop;

to:

childEl[propName] = _.defaultsDeep(prop, childEl[propName]);

@eduardojmatos
Copy link

@marcocarnazzo Thank you so much! :) :) :)

@AgDude
Copy link

AgDude commented Dec 16, 2015

Thanks @marcocarnazzo , this is useful hook. I had some trouble with old dependencies, and the error logging was not helpful, because it caused an error itself. stdout.write needs to take a string, not an exception object.

process.stdout.write(e.toString() + '\n');

@pranit21
Copy link

pranit21 commented Jan 9, 2016

Thanks @marcocarnazzo, this is really very helpful hook. But I got xml error while building an android application "error: Error parsing XML: unbound prefix".
I was able to solve it by adding

xmlns:android="http://schemas.android.com/apk/res/android"

in widget tag at the top in config.xml file. Hope this helps someone.

Also if I want to use multiple <uses-feature> tag, then it adds only once in AndroidManifest.xml file. I just changed in updateAndroidManifest function from

if(childSelector === 'uses-permission') {
      childSelector += '[@android:name=\'' + data.attrib['android:name'] + '\']';
}

to

if(childSelector === 'uses-permission') {
      childSelector += '[@android:name=\'' + data.attrib['android:name'] + '\']';
} else if(childSelector === 'uses-feature') {
      childSelector += '[@android:name=\'' + data.attrib['android:name'] + '\']';
}

@ceoaliongroo
Copy link

Change _.indexBy => _.keyBy

@atfornes
Copy link

👍 @ceoallongroo, It does not work for me without your proposed change:
_.indexBy => _.keyBy

@mtshare
Copy link

mtshare commented Mar 31, 2016

With the last node version (5.9.1) i get this error:

`net.js:625
throw new TypeError('invalid data');
^

TypeError: invalid data
at Socket.write (net.js:625:11)
at /Applications/MAMP/htdocs/Didiha/hooks/after_platform_add/030_update_platform_config.js:366:32
at arrayEach (/Applications/MAMP/htdocs/Didiha/node_modules/lodash/index.js:1289:13)
at Function. (/Applications/MAMP/htdocs/Didiha/node_modules/lodash/index.js:3345:13)
at /Applications/MAMP/htdocs/Didiha/hooks/after_platform_add/030_update_platform_config.js:361:11
at Object. (/Applications/MAMP/htdocs/Didiha/hooks/after_platform_add/030_update_platform_config.js:370:3)
at Module._compile (module.js:413:34)
at Object.Module._extensions..js (module.js:422:10)
at Module.load (module.js:357:32)
at Function.Module._load (module.js:314:12)
Error: Hook failed with error code 1: /Applications/MAMP/htdocs/Didiha/hooks/after_platform_add/030_update_platform_config.js
`

This doesn't happens if i remove these lines from my config file:

<config-file platform="ios" target="*-Info.plist" parent="NSAppTransportSecurity"> <dict> <key>NSAllowsArbitraryLoads</key> <true /> </dict> </config-file> <config-file platform="ios" target="*-Info.plist" parent="CFBundleDevelopmentRegion"> <string>Italy</string> </config-file>

@jeffbonnes
Copy link

This is awesome - thank you! @mtshare, you need to make the change mentioned by @ceoaliongroo and @pranit21 to make this work.

Can we put this under source control again? It would be helpful to be able to submit pull requests.

@Freundschaft
Copy link

_.indexBy has been replaced by _.keyBy in lodash by the way

@sur
Copy link

sur commented Oct 19, 2016

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 !

@halyburton
Copy link

halyburton commented Oct 20, 2016

@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.

@petarov
Copy link

petarov commented Nov 11, 2016

@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!

@matthildenbrand
Copy link

matthildenbrand commented Feb 18, 2017

@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!

@davorpeic
Copy link

+1 that this should be in repo so it can be edited and updated

@ThorvaldAagaard
Copy link

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

@ljudbane
Copy link

ljudbane commented May 9, 2017

@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.

@prescindivel
Copy link

My AndroidManifest xml not being modified 😢

@binodpanta
Copy link

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

@marcocarnazzo
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment