| /** |
| 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. |
| */ |
| |
| /* |
| * This module deals with shared configuration / dependency "stuff". That is: |
| * - XML configuration files such as config.xml, AndroidManifest.xml or WMAppManifest.xml. |
| * - plist files in iOS |
| * Essentially, any type of shared resources that we need to handle with awareness |
| * of how potentially multiple plugins depend on a single shared resource, should be |
| * handled in this module. |
| * |
| * The implementation uses an object as a hash table, with "leaves" of the table tracking |
| * reference counts. |
| */ |
| |
| const path = require('path'); |
| const et = require('elementtree'); |
| const ConfigKeeper = require('./ConfigKeeper'); |
| const events = require('../events'); |
| const mungeutil = require('./munge-util'); |
| const xml_helpers = require('../util/xml-helpers'); |
| |
| exports.process = (plugins_dir, project_dir, platform, platformJson, pluginInfoProvider) => { |
| const munger = new PlatformMunger(platform, project_dir, platformJson, pluginInfoProvider); |
| munger.process(plugins_dir); |
| munger.save_all(); |
| }; |
| |
| /****************************************************************************** |
| * PlatformMunger class |
| * |
| * Can deal with config file of a single project. |
| * Parsed config files are cached in a ConfigKeeper object. |
| ******************************************************************************/ |
| class PlatformMunger { |
| constructor (platform, project_dir, platformJson, pluginInfoProvider) { |
| this.platform = platform; |
| this.project_dir = project_dir; |
| this.config_keeper = new ConfigKeeper(project_dir); |
| this.platformJson = platformJson; |
| this.pluginInfoProvider = pluginInfoProvider; |
| } |
| |
| // Write out all unsaved files. |
| save_all () { |
| this.config_keeper.save_all(); |
| this.platformJson.save(); |
| } |
| |
| // Apply a munge object to a single config file. |
| // The remove parameter tells whether to add the change or remove it. |
| apply_file_munge (file, munge, remove) { |
| for (const selector in munge.parents) { |
| for (const mungeElem of munge.parents[selector]) { |
| // this xml child is new, graft it (only if config file exists) |
| const config_file = this.config_keeper.get(this.project_dir, this.platform, file); |
| |
| if (config_file.exists) { |
| const operation = remove ? 'prune_child' : 'graft_child'; |
| config_file[operation](selector, mungeElem); |
| } else { |
| events.emit('warn', `config file ${file} requested for changes not found at ${config_file.filepath}, ignoring`); |
| } |
| } |
| } |
| } |
| |
| remove_plugin_changes (pluginInfo, is_top_level) { |
| const platform_config = this.platformJson.root; |
| const plugin_vars = is_top_level |
| ? platform_config.installed_plugins[pluginInfo.id] |
| : platform_config.dependent_plugins[pluginInfo.id]; |
| let edit_config_changes = null; |
| |
| if (pluginInfo.getEditConfigs) { |
| edit_config_changes = pluginInfo.getEditConfigs(this.platform); |
| } |
| |
| // get config munge, aka how did this plugin change various config files |
| const config_munge = this.generate_plugin_config_munge(pluginInfo, plugin_vars, edit_config_changes); |
| // global munge looks at all plugins' changes to config files |
| const global_munge = platform_config.config_munge; |
| const munge = mungeutil.decrement_munge(global_munge, config_munge); |
| |
| for (const file in munge.files) { |
| this.apply_file_munge(file, munge.files[file], /* remove = */ true); |
| } |
| |
| // Remove from installed_plugins |
| this.platformJson.removePlugin(pluginInfo.id, is_top_level); |
| |
| return this; |
| } |
| |
| add_plugin_changes (pluginInfo, plugin_vars, is_top_level, should_increment, plugin_force) { |
| const platform_config = this.platformJson.root; |
| let config_munge; |
| let edit_config_changes = null; |
| |
| if (pluginInfo.getEditConfigs) { |
| edit_config_changes = pluginInfo.getEditConfigs(this.platform); |
| } |
| |
| if (!edit_config_changes || edit_config_changes.length === 0) { |
| // get config munge, aka how should this plugin change various config files |
| config_munge = this.generate_plugin_config_munge(pluginInfo, plugin_vars); |
| } else { |
| const isConflictingInfo = this._is_conflicting(edit_config_changes, platform_config.config_munge, plugin_force); |
| |
| if (isConflictingInfo.conflictWithConfigxml) { |
| throw new Error(`${pluginInfo.id} cannot be added. <edit-config> changes in this plugin conflicts with <edit-config> changes in config.xml. Conflicts must be resolved before plugin can be added.`); |
| } |
| if (plugin_force) { |
| events.emit('warn', '--force is used. edit-config will overwrite conflicts if any. Conflicting plugins may not work as expected.'); |
| |
| // remove conflicting munges |
| const conflict_munge = mungeutil.decrement_munge(platform_config.config_munge, isConflictingInfo.conflictingMunge); |
| for (const conflict_file in conflict_munge.files) { |
| this.apply_file_munge(conflict_file, conflict_munge.files[conflict_file], /* remove = */ true); |
| } |
| |
| // force add new munges |
| config_munge = this.generate_plugin_config_munge(pluginInfo, plugin_vars, edit_config_changes); |
| } else if (isConflictingInfo.conflictFound) { |
| throw new Error(`There was a conflict trying to modify attributes with <edit-config> in plugin ${pluginInfo.id}. The conflicting plugin, ${isConflictingInfo.conflictingPlugin}, already modified the same attributes. The conflict must be resolved before ${pluginInfo.id} can be added. You may use --force to add the plugin and overwrite the conflicting attributes.`); |
| } else { |
| // no conflicts, will handle edit-config |
| config_munge = this.generate_plugin_config_munge(pluginInfo, plugin_vars, edit_config_changes); |
| } |
| } |
| |
| this._munge_helper(should_increment, platform_config, config_munge); |
| |
| // Move to installed/dependent_plugins |
| this.platformJson.addPlugin(pluginInfo.id, plugin_vars || {}, is_top_level); |
| |
| return this; |
| } |
| |
| // Handle edit-config changes from config.xml |
| add_config_changes (config, should_increment) { |
| const platform_config = this.platformJson.root; |
| let changes = []; |
| |
| if (config.getEditConfigs) { |
| const edit_config_changes = config.getEditConfigs(this.platform); |
| if (edit_config_changes) { |
| changes = changes.concat(edit_config_changes); |
| } |
| } |
| |
| if (config.getConfigFiles) { |
| const config_files_changes = config.getConfigFiles(this.platform); |
| if (config_files_changes) { |
| changes = changes.concat(config_files_changes); |
| } |
| } |
| |
| if (changes && changes.length > 0) { |
| const isConflictingInfo = this._is_conflicting(changes, platform_config.config_munge, true /* always force overwrite other edit-config */); |
| if (isConflictingInfo.conflictFound) { |
| if (Object.keys(isConflictingInfo.configxmlMunge.files).length !== 0) { |
| // silently remove conflicting config.xml munges so new munges can be added |
| const conflict_munge = mungeutil.decrement_munge(platform_config.config_munge, isConflictingInfo.configxmlMunge); |
| for (const conflict_file in conflict_munge.files) { |
| this.apply_file_munge(conflict_file, conflict_munge.files[conflict_file], /* remove = */ true); |
| } |
| } |
| |
| if (Object.keys(isConflictingInfo.conflictingMunge.files).length !== 0) { |
| events.emit('warn', 'Conflict found, edit-config changes from config.xml will overwrite plugin.xml changes'); |
| |
| // remove conflicting plugin.xml munges |
| const conflict_munge = mungeutil.decrement_munge(platform_config.config_munge, isConflictingInfo.conflictingMunge); |
| for (const conflict_file in conflict_munge.files) { |
| this.apply_file_munge(conflict_file, conflict_munge.files[conflict_file], /* remove = */ true); |
| } |
| } |
| } |
| } |
| |
| // Add config.xml edit-config and config-file munges |
| const config_munge = this.generate_config_xml_munge(config, changes, 'config.xml'); |
| this._munge_helper(should_increment, platform_config, config_munge); |
| |
| // Move to installed/dependent_plugins |
| return this; |
| } |
| |
| /** @private */ |
| _munge_helper (should_increment, platform_config, config_munge) { |
| // global munge looks at all changes to config files |
| // TODO: The should_increment param is only used by cordova-cli and is going away soon. |
| // If should_increment is set to false, avoid modifying the global_munge (use clone) |
| // and apply the entire config_munge because it's already a proper subset of the global_munge. |
| |
| const global_munge = platform_config.config_munge; |
| const munge = should_increment |
| ? mungeutil.increment_munge(global_munge, config_munge) |
| : config_munge; |
| |
| for (const file in munge.files) { |
| this.apply_file_munge(file, munge.files[file]); |
| } |
| |
| return this; |
| } |
| |
| // Load the global munge from platform json and apply all of it. |
| // Used by cordova prepare to re-generate some config file from platform |
| // defaults and the global munge. |
| reapply_global_munge () { |
| const platform_config = this.platformJson.root; |
| const global_munge = platform_config.config_munge; |
| for (const file in global_munge.files) { |
| this.apply_file_munge(file, global_munge.files[file]); |
| } |
| |
| return this; |
| } |
| |
| // generate_config_xml_munge |
| // Generate the munge object from config.xml |
| generate_config_xml_munge (config, config_changes, type) { |
| const munge = { files: {} }; |
| |
| if (!config_changes) return munge; |
| |
| const id = type === 'config.xml' ? type : config.id; |
| |
| config_changes.forEach(change => { |
| change.xmls.forEach(xml => { |
| // 1. stringify each xml |
| const stringified = (new et.ElementTree(xml)).write({ xml_declaration: false }); |
| // 2. add into munge |
| if (change.mode) { |
| mungeutil.deep_add(munge, change.file, change.target, { xml: stringified, count: 1, mode: change.mode, id }); |
| } else { |
| mungeutil.deep_add(munge, change.target, change.parent, { xml: stringified, count: 1, after: change.after }); |
| } |
| }); |
| }); |
| |
| return munge; |
| } |
| |
| // generate_plugin_config_munge |
| // Generate the munge object from plugin.xml + vars |
| generate_plugin_config_munge (pluginInfo, vars, edit_config_changes) { |
| vars = vars || {}; |
| const munge = { files: {} }; |
| const changes = pluginInfo.getConfigFiles(this.platform); |
| |
| if (edit_config_changes) { |
| Array.prototype.push.apply(changes, edit_config_changes); |
| } |
| |
| changes.forEach(change => { |
| change.xmls.forEach(xml => { |
| // 1. stringify each xml |
| let stringified = (new et.ElementTree(xml)).write({ xml_declaration: false }); |
| // interp vars |
| Object.keys(vars).forEach(key => { |
| const regExp = new RegExp(`\\$${key}`, 'g'); |
| stringified = stringified.replace(regExp, vars[key]); |
| }); |
| // 2. add into munge |
| if (change.mode) { |
| if (change.mode !== 'remove') { |
| mungeutil.deep_add(munge, change.file, change.target, { xml: stringified, count: 1, mode: change.mode, plugin: pluginInfo.id }); |
| } |
| } else { |
| mungeutil.deep_add(munge, change.target, change.parent, { xml: stringified, count: 1, after: change.after }); |
| } |
| }); |
| }); |
| |
| return munge; |
| } |
| |
| /** @private */ |
| _is_conflicting (editchanges, config_munge, force) { |
| const files = config_munge.files; |
| let conflictFound = false; |
| let conflictWithConfigxml = false; |
| const conflictingMunge = { files: {} }; |
| const configxmlMunge = { files: {} }; |
| let conflictingParent; |
| let conflictingPlugin; |
| |
| editchanges.forEach(editchange => { |
| if (files[editchange.file]) { |
| const parents = files[editchange.file].parents; |
| let target = parents[editchange.target]; |
| |
| // Check if the edit target will resolve to an existing target |
| if (!target || target.length === 0) { |
| const targetFile = this.config_keeper.get(this.project_dir, this.platform, editchange.file); |
| |
| // For non-XML files (e.g. plist), the selector in editchange.target uniquely identifies its target. |
| // Thus we already know that we have no conflict if we are not dealing with an XML file here. |
| if (targetFile.type !== 'xml') return; |
| |
| // For XML files, the selector does NOT uniquely identify its target. So we resolve editchange.target |
| // and any existing selectors to their matched elements and compare those for equality. |
| const resolveEditTarget = xml_helpers.resolveParent(targetFile.data, editchange.target); |
| if (resolveEditTarget) { |
| for (const parent in parents) { |
| const resolveTarget = xml_helpers.resolveParent(targetFile.data, parent); |
| if (resolveEditTarget === resolveTarget) { |
| conflictingParent = parent; |
| target = parents[parent]; |
| break; |
| } |
| } |
| } |
| } else { |
| conflictingParent = editchange.target; |
| } |
| |
| if (target && target.length !== 0) { |
| // conflict has been found |
| conflictFound = true; |
| |
| if (editchange.id === 'config.xml') { |
| if (target[0].id === 'config.xml') { |
| // Keep track of config.xml/config.xml edit-config conflicts |
| mungeutil.deep_add(configxmlMunge, editchange.file, conflictingParent, target[0]); |
| } else { |
| // Keep track of config.xml x plugin.xml edit-config conflicts |
| mungeutil.deep_add(conflictingMunge, editchange.file, conflictingParent, target[0]); |
| } |
| } else { |
| if (target[0].id === 'config.xml') { |
| // plugin.xml cannot overwrite config.xml changes even if --force is used |
| conflictWithConfigxml = true; |
| return; |
| } |
| |
| if (force) { |
| // Need to find all conflicts when --force is used, track conflicting munges |
| mungeutil.deep_add(conflictingMunge, editchange.file, conflictingParent, target[0]); |
| } else { |
| // plugin cannot overwrite other plugin changes without --force |
| conflictingPlugin = target[0].plugin; |
| } |
| } |
| } |
| } |
| }); |
| |
| return { |
| conflictFound, |
| conflictingPlugin, |
| conflictingMunge, |
| configxmlMunge, |
| conflictWithConfigxml |
| }; |
| } |
| |
| // Go over the prepare queue and apply the config munges for each plugin |
| // that has been (un)installed. |
| process (plugins_dir) { |
| const platform_config = this.platformJson.root; |
| |
| // Uninstallation first |
| platform_config.prepare_queue.uninstalled.forEach(u => { |
| const pluginInfo = this.pluginInfoProvider.get(path.join(plugins_dir, u.plugin)); |
| this.remove_plugin_changes(pluginInfo, u.topLevel); |
| }); |
| |
| // Now handle installation |
| platform_config.prepare_queue.installed.forEach(u => { |
| const pluginInfo = this.pluginInfoProvider.get(path.join(plugins_dir, u.plugin)); |
| this.add_plugin_changes(pluginInfo, u.vars, u.topLevel, true, u.force); |
| }); |
| |
| // Empty out installed/ uninstalled queues. |
| platform_config.prepare_queue.uninstalled = []; |
| platform_config.prepare_queue.installed = []; |
| } |
| } |
| |
| exports.PlatformMunger = PlatformMunger; |