blob: 39970f0c43a4ff2b48548d932137383a5e761db8 [file] [log] [blame]
/**
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.
*/
/*
Helper for dealing with Windows Store JS app .jsproj files
*/
var fs = require('fs');
var et = require('elementtree');
var path = require('path');
var util = require('util');
var semver = require('semver');
var shell = require('shelljs');
var AppxManifest = require('./AppxManifest');
var PluginHandler = require('./PluginHandler');
var events = require('cordova-common').events;
var CordovaError = require('cordova-common').CordovaError;
var xml_helpers = require('cordova-common').xmlHelpers;
var WinCSharpProjectTypeGUID = '{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}'; // .csproj
var WinCplusplusProjectTypeGUID = '{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}'; // .vcxproj
// Match a JavaScript Project
var JSPROJ_REGEXP = /(Project\("\{262852C6-CD72-467D-83FE-5EEB1973A190}"\)\s*=\s*"[^"]+",\s*"[^"]+",\s*"\{[0-9a-f\-]+}"[^\r\n]*[\r\n]*)/gi;
// Chars in a string that need to be escaped when used in a RegExp
var ESCAPE_REGEXP = /([.?*+\^$\[\]\\(){}|\-])/g; /* eslint no-useless-escape : 0 */
function jsprojManager (location) {
this.root = path.dirname(location);
this.isUniversalWindowsApp = path.extname(location).toLowerCase() === '.projitems';
this.projects = [];
this.master = this.isUniversalWindowsApp ? new proj(location) : new jsproj(location);
this.projectFolder = path.dirname(location); /* eslint new-cap : 0 */
this.www = path.join(this.root, 'www');
this.platformWww = path.join(this.root, 'platform_www');
}
function getProjectName (pluginProjectXML, relative_path) {
var projNameElt = pluginProjectXML.find('PropertyGroup/ProjectName');
// Falling back on project file name in case ProjectName is missing
return projNameElt ? projNameElt.text : path.basename(relative_path, path.extname(relative_path));
}
jsprojManager.getProject = function (directory) {
var projectFiles = shell.ls(path.join(directory, '*.projitems'));
if (projectFiles.length === 0) {
throw (new CordovaError('The directory ' + directory +
' does not appear to be a Unified Windows Store project (no .projitems file found)'));
}
return new jsprojManager(path.normalize(projectFiles[0]));
};
jsprojManager.prototype = {
_projects: null,
getPackageName: function () {
// CB-10394 Do not cache manifest file while getting package name to avoid problems
// with windows.appxmanifest cached twice (here and in ConfigFile module)
return AppxManifest.get(path.join(this.root, 'package.windows.appxmanifest'), /* ignoreCache= */true)
.getProperties().getDisplayName();
},
write: function () {
this.master.write();
if (this._projects) {
var that = this;
this._projects.forEach(function (project) {
if (project !== that.master && project.touched) {
project.write();
}
});
}
},
addSDKRef: function (incText, targetConditions) {
events.emit('verbose', 'jsprojManager.addSDKRef(incText: ' + incText + ', targetConditions: ' + JSON.stringify(targetConditions) + ')');
var item = createItemGroupElement('ItemGroup/SDKReference', incText, targetConditions);
this._getMatchingProjects(targetConditions).forEach(function (project) {
project.appendToRoot(item);
});
},
removeSDKRef: function (incText, targetConditions) {
events.emit('verbose', 'jsprojManager.removeSDKRef(incText: ' + incText + ', targetConditions: ' + JSON.stringify(targetConditions) + ')');
this._getMatchingProjects(targetConditions).forEach(function (project) {
project.removeItemGroupElement('ItemGroup/SDKReference', incText, targetConditions);
});
},
addResourceFileToProject: function (sourcePath, destPath, targetConditions) {
events.emit('verbose', 'jsprojManager.addResourceFile(sourcePath: ' + sourcePath + ', destPath: ' + destPath + ', targetConditions: ' + JSON.stringify(targetConditions) + ')');
// add hint path with full path
var link = new et.Element('Link');
link.text = destPath;
var children = [link];
var copyToOutputDirectory = new et.Element('CopyToOutputDirectory');
copyToOutputDirectory.text = 'Always';
children.push(copyToOutputDirectory);
var item = createItemGroupElement('ItemGroup/Content', sourcePath, targetConditions, children);
this._getMatchingProjects(targetConditions).forEach(function (project) {
project.appendToRoot(item);
});
},
removeResourceFileFromProject: function (relPath, targetConditions) {
events.emit('verbose', 'jsprojManager.removeResourceFile(relPath: ' + relPath + ', targetConditions: ' + JSON.stringify(targetConditions) + ')');
this._getMatchingProjects(targetConditions).forEach(function (project) {
project.removeItemGroupElement('ItemGroup/Content', relPath, targetConditions);
});
},
addReference: function (relPath, targetConditions, implPath) {
events.emit('verbose', 'jsprojManager.addReference(incText: ' + relPath + ', targetConditions: ' + JSON.stringify(targetConditions) + ')');
// add hint path with full path
var hint_path = new et.Element('HintPath');
hint_path.text = relPath;
var children = [hint_path];
var extName = path.extname(relPath);
if (extName === '.winmd') {
var mdFileTag = new et.Element('IsWinMDFile');
mdFileTag.text = 'true';
children.push(mdFileTag);
}
// We only need to add <Implementation> tag when dll base name differs from winmd name
if (implPath && path.basename(relPath, '.winmd') !== path.basename(implPath, '.dll')) {
var implementTag = new et.Element('Implementation');
implementTag.text = path.basename(implPath);
children.push(implementTag);
}
var item = createItemGroupElement('ItemGroup/Reference', path.basename(relPath, extName), targetConditions, children);
this._getMatchingProjects(targetConditions).forEach(function (project) {
project.appendToRoot(item);
});
},
removeReference: function (relPath, targetConditions) {
events.emit('verbose', 'jsprojManager.removeReference(incText: ' + relPath + ', targetConditions: ' + JSON.stringify(targetConditions) + ')');
var extName = path.extname(relPath);
var includeText = path.basename(relPath, extName);
this._getMatchingProjects(targetConditions).forEach(function (project) {
project.removeItemGroupElement('ItemGroup/Reference', includeText, targetConditions);
});
},
addSourceFile: function (relative_path) {
events.emit('verbose', 'jsprojManager.addSourceFile(relative_path: ' + relative_path + ')');
this.master.addSourceFile(relative_path);
},
removeSourceFile: function (relative_path) {
events.emit('verbose', 'jsprojManager.removeSourceFile(incText: ' + relative_path + ')');
this.master.removeSourceFile(relative_path);
},
addProjectReference: function (relative_path, targetConditions) {
events.emit('verbose', 'jsprojManager.addProjectReference(incText: ' + relative_path + ', targetConditions: ' + JSON.stringify(targetConditions) + ')');
// relative_path is the actual path to the file in the current OS, where-as inserted_path is what we write in
// the project file, and is always in Windows format.
relative_path = path.normalize(relative_path);
var inserted_path = relative_path.split('/').join('\\');
var pluginProjectXML = xml_helpers.parseElementtreeSync(path.resolve(this.projectFolder, relative_path));
// find the guid + name of the referenced project
var projectGuid = pluginProjectXML.find('PropertyGroup/ProjectGuid').text;
var projName = getProjectName(pluginProjectXML, relative_path);
// get the project type
var projectTypeGuid = getProjectTypeGuid(relative_path);
if (!projectTypeGuid) {
throw new CordovaError('Unrecognized project type at ' + relative_path + ' (not .csproj or .vcxproj)');
}
var preInsertText = '\tProjectSection(ProjectDependencies) = postProject\r\n' +
'\t\t' + projectGuid + '=' + projectGuid + '\r\n' +
'\tEndProjectSection\r\n';
var postInsertText = '\r\nProject("' + projectTypeGuid + '") = "' +
projName + '", "' + inserted_path + '", ' +
'"' + projectGuid + '"\r\nEndProject';
var matchingProjects = this._getMatchingProjects(targetConditions);
if (matchingProjects.length === 0) {
// No projects meet the specified target and version criteria, so nothing to do.
return;
}
// Will we be writing into the .projitems file rather than individual .jsproj files?
var useProjItems = this.isUniversalWindowsApp && matchingProjects.length === 1 && matchingProjects[0] === this.master;
// There may be multiple solution files (for different VS versions) - process them all
getSolutionPaths(this.projectFolder).forEach(function (solutionPath) {
var solText = fs.readFileSync(solutionPath, { encoding: 'utf8' });
if (useProjItems) {
// Insert a project dependency into every jsproj in the solution.
var jsProjectFound = false;
solText = solText.replace(JSPROJ_REGEXP, function (match) {
jsProjectFound = true;
return match + preInsertText;
});
if (!jsProjectFound) {
throw new CordovaError('No jsproj found in solution');
}
} else {
// Insert a project dependency only for projects that match specified target and version
matchingProjects.forEach(function (project) {
solText = solText.replace(getJsProjRegExForProject(path.basename(project.location)), function (match) {
return match + preInsertText;
});
});
}
// Add the project after existing projects. Note that this fairly simplistic check should be fine, since the last
// EndProject in the file should actually be an EndProject (and not an EndProjectSection, for example).
var pos = solText.lastIndexOf('EndProject');
if (pos === -1) {
throw new Error('No EndProject found in solution');
}
pos += 10; // Move pos to the end of EndProject text
solText = solText.slice(0, pos) + postInsertText + solText.slice(pos);
fs.writeFileSync(solutionPath, solText, { encoding: 'utf8' });
});
// Add the ItemGroup/ProjectReference to each matching cordova project :
// <ItemGroup><ProjectReference Include="blahblah.csproj"/></ItemGroup>
var item = createItemGroupElement('ItemGroup/ProjectReference', inserted_path, targetConditions);
matchingProjects.forEach(function (project) {
project.appendToRoot(item);
});
},
removeProjectReference: function (relative_path, targetConditions) {
events.emit('verbose', 'jsprojManager.removeProjectReference(incText: ' + relative_path + ', targetConditions: ' + JSON.stringify(targetConditions) + ')');
// relative_path is the actual path to the file in the current OS, where-as inserted_path is what we write in
// the project file, and is always in Windows format.
relative_path = path.normalize(relative_path);
var inserted_path = relative_path.split('/').join('\\');
// find the guid + name of the referenced project
var pluginProjectXML = xml_helpers.parseElementtreeSync(path.resolve(this.projectFolder, relative_path));
var projectGuid = pluginProjectXML.find('PropertyGroup/ProjectGuid').text;
var projName = getProjectName(pluginProjectXML, relative_path);
// get the project type
var projectTypeGuid = getProjectTypeGuid(relative_path);
if (!projectTypeGuid) {
throw new Error('Unrecognized project type at ' + relative_path + ' (not .csproj or .vcxproj)');
}
var preInsertTextRegExp = getProjectReferencePreInsertRegExp(projectGuid);
var postInsertTextRegExp = getProjectReferencePostInsertRegExp(projName, projectGuid, inserted_path, projectTypeGuid);
// There may be multiple solutions (for different VS versions) - process them all
getSolutionPaths(this.projectFolder).forEach(function (solutionPath) {
var solText = fs.readFileSync(solutionPath, { encoding: 'utf8' });
// To be safe (to handle subtle changes in formatting, for example), use a RegExp to find and remove
// preInsertText and postInsertText
solText = solText.replace(preInsertTextRegExp, function () {
return '';
});
solText = solText.replace(postInsertTextRegExp, function () {
return '';
});
fs.writeFileSync(solutionPath, solText, { encoding: 'utf8' });
});
this._getMatchingProjects(targetConditions).forEach(function (project) {
project.removeItemGroupElement('ItemGroup/ProjectReference', inserted_path, targetConditions);
});
},
_getMatchingProjects: function (targetConditions) {
// If specified, target can be 'all' (default), 'phone' or 'windows'. Ultimately should probably allow a comma
// separated list, but not needed now.
var target = getDeviceTarget(targetConditions);
var versions = getVersions(targetConditions);
if (target || versions) {
var matchingProjects = this.projects.filter(function (project) {
return (!target || target === project.target) &&
(!versions || semver.satisfies(project.getSemVersion(), versions, /* loose */ true));
});
if (matchingProjects.length < this.projects.length) {
return matchingProjects;
}
}
// All projects match. If this is a universal project, return the projitems file. Otherwise return our single
// project.
return [this.master];
},
get projects () {
var projects = this._projects;
if (!projects) {
projects = [];
this._projects = projects;
if (this.isUniversalWindowsApp) {
var projectPath = this.projectFolder;
var projectFiles = shell.ls(path.join(projectPath, '*.jsproj'));
projectFiles.forEach(function (projectFile) {
projects.push(new jsproj(projectFile));
});
} else {
this.projects.push(this.master);
}
}
return projects;
}
};
jsprojManager.prototype.getInstaller = function (type) {
return PluginHandler.getInstaller(type);
};
jsprojManager.prototype.getUninstaller = function (type) {
return PluginHandler.getUninstaller(type);
};
function getProjectReferencePreInsertRegExp (projectGuid) {
projectGuid = escapeRegExpString(projectGuid);
return new RegExp('\\s*ProjectSection\\(ProjectDependencies\\)\\s*=\\s*postProject\\s*' + projectGuid + '\\s*=\\s*' + projectGuid + '\\s*EndProjectSection', 'gi');
}
function getProjectReferencePostInsertRegExp (projName, projectGuid, relative_path, projectTypeGuid) {
projName = escapeRegExpString(projName);
projectGuid = escapeRegExpString(projectGuid);
relative_path = escapeRegExpString(relative_path);
projectTypeGuid = escapeRegExpString(projectTypeGuid);
return new RegExp('\\s*Project\\("' + projectTypeGuid + '"\\)\\s*=\\s*"' + projName + '"\\s*,\\s*"' + relative_path + '"\\s*,\\s*"' + projectGuid + '"\\s*EndProject', 'gi');
}
function getSolutionPaths (projectFolder) {
return shell.ls(path.join(projectFolder, '*.sln'));
}
function escapeRegExpString (regExpString) {
return regExpString.replace(ESCAPE_REGEXP, '\\$1');
}
function getJsProjRegExForProject (projectFile) {
projectFile = escapeRegExpString(projectFile);
return new RegExp('(Project\\("\\{262852C6-CD72-467D-83FE-5EEB1973A190}"\\)\\s*=\\s*"[^"]+",\\s*"' + projectFile + '",\\s*"\\{[0-9a-f\\-]+}"[^\\r\\n]*[\\r\\n]*)', 'gi');
}
function getProjectTypeGuid (projectPath) {
switch (path.extname(projectPath)) {
case '.vcxproj':
return WinCplusplusProjectTypeGUID;
case '.csproj':
return WinCSharpProjectTypeGUID;
}
return null;
}
function createItemGroupElement (path, incText, targetConditions, children) {
path = path.split('/');
path.reverse();
var lastElement = null;
path.forEach(function (elementName) {
var element = new et.Element(elementName);
if (lastElement) {
element.append(lastElement);
} else {
element.attrib.Include = incText;
var condition = createConditionAttrib(targetConditions);
if (condition) {
element.attrib.Condition = condition;
}
if (children) {
children.forEach(function (child) {
element.append(child);
});
}
}
lastElement = element;
});
return lastElement;
}
function getDeviceTarget (targetConditions) {
var target = targetConditions.deviceTarget;
if (target) {
target = target.toLowerCase().trim();
if (target === 'all') {
target = null;
} else if (target === 'win') {
// Allow "win" as alternative to "windows"
target = 'windows';
} else if (target !== 'phone' && target !== 'windows') {
throw new Error('Invalid device-target attribute (must be "all", "phone", "windows" or "win"): ' + target);
}
}
return target;
}
function getVersions (targetConditions) {
var versions = targetConditions.versions;
if (versions && !semver.validRange(versions, /* loose */ true)) {
throw new Error('Invalid versions attribute (must be a valid semantic version range): ' + versions);
}
return versions;
}
/* proj */
function proj (location) {
// Class to handle simple project xml operations
if (!location) {
throw new Error('Project file location can\'t be null or empty');
}
this.location = location;
this.xml = xml_helpers.parseElementtreeSync(location);
}
proj.prototype = {
write: function () {
fs.writeFileSync(this.location, this.xml.write({ indent: 4 }), 'utf-8');
},
appendToRoot: function (element) {
this.touched = true;
this.xml.getroot().append(element);
},
removeItemGroupElement: function (path, incText, targetConditions) {
var xpath = path + '[@Include="' + incText + '"]';
var condition = createConditionAttrib(targetConditions);
if (condition) {
xpath += '[@Condition="' + condition + '"]';
}
xpath += '/..';
var itemGroup = this.xml.find(xpath);
if (itemGroup) {
this.touched = true;
this.xml.getroot().remove(itemGroup);
}
},
addSourceFile: function (relative_path) {
// we allow multiple paths to be passed at once as array so that
// we don't create separate ItemGroup for each source file, CB-6874
if (!(relative_path instanceof Array)) {
relative_path = [relative_path];
}
// make ItemGroup to hold file.
var item = new et.Element('ItemGroup');
relative_path.forEach(function (filePath) {
// filePath is never used to find the actual file - it determines what we write to the project file, and so
// should always be in Windows format.
filePath = filePath.split('/').join('\\');
var content = new et.Element('Content');
content.attrib.Include = filePath;
item.append(content);
});
this.appendToRoot(item);
},
removeSourceFile: function (relative_path) {
var isRegexp = relative_path instanceof RegExp;
if (!isRegexp) {
// relative_path is never used to find the actual file - it determines what we write to the project file,
// and so should always be in Windows format.
relative_path = relative_path.split('/').join('\\');
}
var root = this.xml.getroot();
var that = this;
// iterate through all ItemGroup/Content elements and remove all items matched
this.xml.findall('ItemGroup').forEach(function (group) {
// matched files in current ItemGroup
var filesToRemove = group.findall('Content').filter(function (item) {
if (!item.attrib.Include) {
return false;
}
return isRegexp ? item.attrib.Include.match(relative_path) : item.attrib.Include === relative_path;
});
// nothing to remove, skip..
if (filesToRemove.length < 1) {
return;
}
filesToRemove.forEach(function (file) {
// remove file reference
group.remove(file);
});
// remove ItemGroup if empty
if (group.findall('*').length < 1) {
that.touched = true;
root.remove(group);
}
});
}
};
/* jsproj */
function jsproj (location) {
function targetPlatformIdentifierToDevice (jsprojPlatform) {
var index = ['Windows', 'WindowsPhoneApp', 'UAP'].indexOf(jsprojPlatform);
if (index < 0) {
throw new Error("Unknown TargetPlatformIdentifier '" + jsprojPlatform + "' in project file '" + location + "'");
}
return ['windows', 'phone', 'windows'][index];
}
function validateVersion (version) {
version = version.split('.');
while (version.length < 3) {
version.push('0');
}
return version.join('.');
}
// Class to handle a jsproj file
proj.call(this, location);
var propertyGroup = this.xml.find('PropertyGroup[TargetPlatformIdentifier]');
if (!propertyGroup) {
throw new Error("Unable to find PropertyGroup/TargetPlatformIdentifier in project file '" + this.location + "'");
}
var jsprojPlatform = propertyGroup.find('TargetPlatformIdentifier').text;
this.target = targetPlatformIdentifierToDevice(jsprojPlatform);
var version = propertyGroup.find('TargetPlatformVersion');
if (!version) {
throw new Error("Unable to find PropertyGroup/TargetPlatformVersion in project file '" + this.location + "'");
}
this.version = validateVersion(version.text);
}
util.inherits(jsproj, proj);
jsproj.prototype.target = null;
jsproj.prototype.version = null;
// Returns valid semantic version (http://semver.org/).
jsproj.prototype.getSemVersion = function () {
// For example, for version 10.0.10240.0 we will return 10.0.10240 (first three components)
var semVersion = this.version;
var splittedVersion = semVersion.split('.');
if (splittedVersion.length > 3) {
semVersion = splittedVersion.splice(0, 3).join('.');
}
return semVersion;
// Alternative approach could be replacing last dot with plus sign to
// be complaint w/ semver specification, for example
// 10.0.10240.0 -> 10.0.10240+0
};
/* Common support functions */
function createConditionAttrib (targetConditions) {
var arch = targetConditions.arch;
if (arch) {
if (arch === 'arm') {
// Specifcally allow "arm" as alternative to "ARM"
arch = 'ARM';
} else if (arch !== 'x86' && arch !== 'x64' && arch !== 'ARM') {
throw new Error('Invalid arch attribute (must be "x86", "x64" or "ARM"): ' + arch);
}
return "'$(Platform)'=='" + arch + "'";
}
return null;
}
module.exports = jsprojManager;