src/extensibility/Package.js
/*
* Copyright (c) 2013 - present Adobe Systems Incorporated. All rights reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*
*/
/*jslint regexp: true */
/**
* Functions for working with extension packages
*/
define(function (require, exports, module) {
"use strict";
var AppInit = require("utils/AppInit"),
FileSystem = require("filesystem/FileSystem"),
FileUtils = require("file/FileUtils"),
StringUtils = require("utils/StringUtils"),
Strings = require("strings"),
ExtensionLoader = require("utils/ExtensionLoader"),
NodeConnection = require("utils/NodeConnection"),
PreferencesManager = require("preferences/PreferencesManager"),
PathUtils = require("thirdparty/path-utils/path-utils");
PreferencesManager.definePreference("proxy", "string", undefined, {
description: Strings.DESCRIPTION_PROXY
});
var PREF_EXTENSIONS_DEFAULT_DISABLED = "extensions.default.disabled";
PreferencesManager.definePreference(PREF_EXTENSIONS_DEFAULT_DISABLED, "array", [], {
description: Strings.DESCRIPTION_DISABLED_DEFAULT_EXTENSIONS
});
var Errors = {
ERROR_LOADING: "ERROR_LOADING",
MALFORMED_URL: "MALFORMED_URL",
UNSUPPORTED_PROTOCOL: "UNSUPPORTED_PROTOCOL"
};
var InstallationStatuses = {
FAILED: "FAILED",
INSTALLED: "INSTALLED",
ALREADY_INSTALLED: "ALREADY_INSTALLED",
SAME_VERSION: "SAME_VERSION",
OLDER_VERSION: "OLDER_VERSION",
NEEDS_UPDATE: "NEEDS_UPDATE",
DISABLED: "DISABLED"
};
/**
* @private
* @type {NodeConnection}
* Connects to ExtensionManagerDomain
*/
var _nodeConnection;
/**
* @private
* @type {jQuery.Deferred.<NodeConnection>}
* A deferred which is resolved with a NodeConnection or rejected if
* we are unable to connect to Node.
*/
var _nodeConnectionDeferred = $.Deferred();
/**
* @type {number} Used to generate unique download ids
*/
var _uniqueId = 0;
function _extensionManagerCall(callback) {
if (_nodeConnection.domains.extensionManager) {
return callback(_nodeConnection.domains.extensionManager);
} else {
return new $.Deferred().reject("extensionManager domain is undefined").promise();
}
}
/**
* TODO: can this go away now that we never call it directly?
*
* Validates the package at the given path. The actual validation is
* handled by the Node server.
*
* The promise is resolved with an object:
* { errors: Array.<{string}>, metadata: { name:string, version:string, ... } }
* metadata is pulled straight from package.json and will be undefined
* if there are errors or null if the extension did not include package.json.
*
* @param {string} Absolute path to the package zip file
* @param {{requirePackageJSON: ?boolean}} validation options
* @return {$.Promise} A promise that is resolved with information about the package
*/
function validate(path, options) {
return _extensionManagerCall(function (extensionManager) {
var d = new $.Deferred();
// make sure proxy is attached to options before calling validate
// so npm can use it in the domain
options = options || {};
options.proxy = PreferencesManager.get("proxy");
extensionManager.validate(path, options)
.done(function (result) {
d.resolve({
errors: result.errors,
metadata: result.metadata
});
})
.fail(function (error) {
d.reject(error);
});
return d.promise();
});
}
/**
* Validates and installs the package at the given path. Validation and
* installation is handled by the Node process.
*
* The extension will be installed into the user's extensions directory.
* If the user already has the extension installed, it will instead go
* into their disabled extensions directory.
*
* The promise is resolved with an object:
* { errors: Array.<{string}>, metadata: { name:string, version:string, ... },
* disabledReason:string, installedTo:string, commonPrefix:string }
* metadata is pulled straight from package.json and is likely to be undefined
* if there are errors. It is null if there was no package.json.
*
* disabledReason is either null or the reason the extension was installed disabled.
*
* @param {string} path Absolute path to the package zip file
* @param {?string} nameHint Hint for the extension folder's name (used in favor of
* path's filename if present, and if no package metadata present).
* @param {?boolean} _doUpdate private argument used to signal an update
* @return {$.Promise} A promise that is resolved with information about the package
* (which may include errors, in which case the extension was disabled), or
* rejected with an error object.
*/
function install(path, nameHint, _doUpdate) {
return _extensionManagerCall(function (extensionManager) {
var d = new $.Deferred(),
destinationDirectory = ExtensionLoader.getUserExtensionPath(),
disabledDirectory = destinationDirectory.replace(/\/user$/, "/disabled"),
systemDirectory = FileUtils.getNativeBracketsDirectoryPath() + "/extensions/default/";
var operation = _doUpdate ? "update" : "install";
extensionManager[operation](path, destinationDirectory, {
disabledDirectory: disabledDirectory,
systemExtensionDirectory: systemDirectory,
apiVersion: brackets.metadata.apiVersion,
nameHint: nameHint,
proxy: PreferencesManager.get("proxy")
})
.done(function (result) {
result.keepFile = false;
if (result.installationStatus !== InstallationStatuses.INSTALLED || _doUpdate) {
d.resolve(result);
} else {
// This was a new extension and everything looked fine.
// We load it into Brackets right away.
ExtensionLoader.loadExtension(result.name, {
// On Windows, it looks like Node converts Unix-y paths to backslashy paths.
// We need to convert them back.
baseUrl: FileUtils.convertWindowsPathToUnixPath(result.installedTo)
}, "main").then(function () {
d.resolve(result);
}, function () {
d.reject(Errors.ERROR_LOADING);
});
}
})
.fail(function (error) {
d.reject(error);
});
return d.promise();
});
}
/**
* Special case handling to make the common case of downloading from GitHub easier; modifies 'urlInfo' as
* needed. Converts a bare GitHub repo URL to the corresponding master ZIP URL; or if given a direct
* master ZIP URL already, sets a nicer download filename (both cases use the repo name).
*
* @param {{url:string, parsed:Array.<string>, filenameHint:string}} urlInfo
*/
function githubURLFilter(urlInfo) {
if (urlInfo.parsed.hostname === "github.com" || urlInfo.parsed.hostname === "www.github.com") {
// Is it a URL to the root of a repo? (/user/repo)
var match = /^\/[^\/?]+\/([^\/?]+)(\/?)$/.exec(urlInfo.parsed.pathname);
if (match) {
if (!match[2]) {
urlInfo.url += "/";
}
urlInfo.url += "archive/master.zip";
urlInfo.filenameHint = match[1] + ".zip";
} else {
// Is it a URL directly to the repo's 'master.zip'? (/user/repo/archive/master.zip)
match = /^\/[^\/?]+\/([^\/?]+)\/archive\/master.zip$/.exec(urlInfo.parsed.pathname);
if (match) {
urlInfo.filenameHint = match[1] + ".zip";
}
}
}
}
/**
* Downloads from the given URL to a temporary location. On success, resolves with the path of the
* downloaded file (typically in a temp folder) and a hint for the real filename. On failure, rejects
* with an error object.
*
* @param {string} url URL of the file to be downloaded
* @param {number} downloadId Unique number to identify this request
* @return {$.Promise}
*/
function download(url, downloadId) {
return _extensionManagerCall(function (extensionManager) {
var d = new $.Deferred();
// Validate URL
// TODO: PathUtils fails to parse URLs that are missing the protocol part (e.g. starts immediately with "www...")
var parsed = PathUtils.parseUrl(url);
if (!parsed.hostname) { // means PathUtils failed to parse at all
d.reject(Errors.MALFORMED_URL);
return d.promise();
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
d.reject(Errors.UNSUPPORTED_PROTOCOL);
return d.promise();
}
var urlInfo = { url: url, parsed: parsed, filenameHint: parsed.filename };
githubURLFilter(urlInfo);
// Decide download destination
var filename = urlInfo.filenameHint;
filename = filename.replace(/[^a-zA-Z0-9_\- \(\)\.]/g, "_"); // make sure it's a valid filename
if (!filename) { // in case of URL ending in "/"
filename = "extension.zip";
}
// Download the bits (using Node since brackets-shell doesn't support binary file IO)
var r = extensionManager.downloadFile(downloadId, urlInfo.url, PreferencesManager.get("proxy"));
r.done(function (result) {
d.resolve({ localPath: FileUtils.convertWindowsPathToUnixPath(result), filenameHint: urlInfo.filenameHint });
}).fail(function (err) {
d.reject(err);
});
return d.promise();
});
}
/**
* Attempts to synchronously cancel the given pending download. This may not be possible, e.g.
* if the download has already finished.
*
* @param {number} downloadId Identifier previously passed to download()
*/
function cancelDownload(downloadId) {
return _extensionManagerCall(function (extensionManager) {
return extensionManager.abortDownload(downloadId);
});
}
/**
* On success, resolves with an extension metadata object; at that point, the extension has already
* started running in Brackets. On failure (including validation errors), rejects with an error object.
*
* An error object consists of either a string error code OR an array where the first entry is the error
* code and the remaining entries are further info. The error code string is one of either
* ExtensionsDomain.Errors or Package.Errors. Use formatError() to convert an error object to a friendly,
* localized error message.
*
* @param {string} path Absolute path to the package zip file
* @param {?string} filenameHint Hint for the extension folder's name (used in favor of
* path's filename if present, and if no package metadata present).
* @return {$.Promise} A promise that is rejected if there are errors during
* install or the extension is disabled.
*/
function installFromPath(path, filenameHint) {
var d = new $.Deferred();
install(path, filenameHint)
.done(function (result) {
result.keepFile = true;
var installationStatus = result.installationStatus;
if (installationStatus === InstallationStatuses.ALREADY_INSTALLED ||
installationStatus === InstallationStatuses.NEEDS_UPDATE ||
installationStatus === InstallationStatuses.SAME_VERSION ||
installationStatus === InstallationStatuses.OLDER_VERSION) {
d.resolve(result);
} else {
if (result.errors && result.errors.length > 0) {
// Validation errors - for now, only return the first one
d.reject(result.errors[0]);
} else if (result.disabledReason) {
// Extension valid but left disabled (wrong API version, extension name collision, etc.)
d.reject(result.disabledReason);
} else {
// Success! Extension is now running in Brackets
d.resolve(result);
}
}
})
.fail(function (err) {
d.reject(err);
});
return d.promise();
}
/**
* On success, resolves with an extension metadata object; at that point, the extension has already
* started running in Brackets. On failure (including validation errors), rejects with an error object.
*
* An error object consists of either a string error code OR an array where the first entry is the error
* code and the remaining entries are further info. The error code string is one of either
* ExtensionsDomain.Errors or Package.Errors. Use formatError() to convert an error object to a friendly,
* localized error message.
*
* The returned cancel() function will *attempt* to cancel installation, but it is not guaranteed to
* succeed. If cancel() succeeds, the Promise is rejected with a CANCELED error code. If we're unable
* to cancel, the Promise is resolved or rejected normally, as if cancel() had never been called.
*
* @return {{promise: $.Promise, cancel: function():boolean}}
*/
function installFromURL(url) {
var STATE_DOWNLOADING = 1,
STATE_INSTALLING = 2,
STATE_SUCCEEDED = 3,
STATE_FAILED = 4;
var d = new $.Deferred();
var state = STATE_DOWNLOADING;
var downloadId = (_uniqueId++);
download(url, downloadId)
.done(function (downloadResult) {
state = STATE_INSTALLING;
installFromPath(downloadResult.localPath, downloadResult.filenameHint)
.done(function (result) {
var installationStatus = result.installationStatus;
state = STATE_SUCCEEDED;
result.localPath = downloadResult.localPath;
result.keepFile = false;
if (installationStatus === InstallationStatuses.INSTALLED) {
// Delete temp file
FileSystem.getFileForPath(downloadResult.localPath).unlink();
}
d.resolve(result);
})
.fail(function (err) {
// File IO errors, internal error in install()/validate(), or extension startup crashed
state = STATE_FAILED;
FileSystem.getFileForPath(downloadResult.localPath).unlink();
d.reject(err); // TODO: needs to be err.message ?
});
})
.fail(function (err) {
// Download error (the Node-side download code cleans up any partial ZIP file)
state = STATE_FAILED;
d.reject(err);
});
return {
promise: d.promise(),
cancel: function () {
if (state === STATE_DOWNLOADING) {
// This will trigger download()'s fail() handler with CANCELED as the err code
cancelDownload(downloadId);
}
// Else it's too late to cancel; we'll continue on through the done() chain and emit
// a success result (calling done() handlers) if all else goes well.
}
};
}
/**
* Converts an error object as returned by install(), installFromPath() or
* installFromURL() into a flattened, localized string.
*
* @param {string|Array.<string>} error
* @return {string}
*/
function formatError(error) {
function localize(key) {
if (Strings[key]) {
return Strings[key];
}
console.log("Unknown installation error", key);
return Strings.UNKNOWN_ERROR;
}
if (Array.isArray(error)) {
error[0] = localize(error[0]);
return StringUtils.format.apply(window, error);
} else {
return localize(error);
}
}
/**
* Removes the extension at the given path.
*
* @param {string} path The absolute path to the extension to remove.
* @return {$.Promise} A promise that's resolved when the extension is removed, or
* rejected if there was an error.
*/
function remove(path) {
return _extensionManagerCall(function (extensionManager) {
return extensionManager.remove(path);
});
}
/**
* This function manages the PREF_EXTENSIONS_DEFAULT_DISABLED preference
* holding an array of default extension paths that should not be loaded
* on Brackets startup
*/
function toggleDefaultExtension(path, enabled) {
var arr = PreferencesManager.get(PREF_EXTENSIONS_DEFAULT_DISABLED);
if (!Array.isArray(arr)) { arr = []; }
var io = arr.indexOf(path);
if (enabled === true && io !== -1) {
arr.splice(io, 1);
} else if (enabled === false && io === -1) {
arr.push(path);
}
PreferencesManager.set(PREF_EXTENSIONS_DEFAULT_DISABLED, arr);
}
/**
* Disables the extension at the given path.
*
* @param {string} path The absolute path to the extension to disable.
* @return {$.Promise} A promise that's resolved when the extenion is disabled, or
* rejected if there was an error.
*/
function disable(path) {
var result = new $.Deferred(),
file = FileSystem.getFileForPath(path + "/.disabled");
var defaultExtensionPath = ExtensionLoader.getDefaultExtensionPath();
if (file.fullPath.indexOf(defaultExtensionPath) === 0) {
toggleDefaultExtension(path, false);
result.resolve();
return result.promise();
}
file.write("", function (err) {
if (err) {
return result.reject(err);
}
result.resolve();
});
return result.promise();
}
/**
* Enables the extension at the given path.
*
* @param {string} path The absolute path to the extension to enable.
* @return {$.Promise} A promise that's resolved when the extenion is enable, or
* rejected if there was an error.
*/
function enable(path) {
var result = new $.Deferred(),
file = FileSystem.getFileForPath(path + "/.disabled");
function afterEnable() {
ExtensionLoader.loadExtension(FileUtils.getBaseName(path), { baseUrl: path }, "main")
.done(result.resolve)
.fail(result.reject);
}
var defaultExtensionPath = ExtensionLoader.getDefaultExtensionPath();
if (file.fullPath.indexOf(defaultExtensionPath) === 0) {
toggleDefaultExtension(path, true);
afterEnable();
return result.promise();
}
file.unlink(function (err) {
if (err) {
return result.reject(err);
}
afterEnable();
});
return result.promise();
}
/**
* Install an extension update located at path.
* This assumes that the installation was previously attempted
* and an installationStatus of "ALREADY_INSTALLED", "NEEDS_UPDATE", "SAME_VERSION",
* or "OLDER_VERSION" was the result.
*
* This workflow ensures that there should not generally be validation errors
* because the first pass at installation the extension looked at the metadata
* and installed packages.
*
* @param {string} path to package file
* @param {?string} nameHint Hint for the extension folder's name (used in favor of
* path's filename if present, and if no package metadata present).
* @return {$.Promise} A promise that is resolved when the extension is successfully
* installed or rejected if there is a problem.
*/
function installUpdate(path, nameHint) {
var d = new $.Deferred();
install(path, nameHint, true)
.done(function (result) {
if (result.installationStatus !== InstallationStatuses.INSTALLED) {
d.reject(result.errors);
} else {
d.resolve(result);
}
})
.fail(function (error) {
d.reject(error);
});
return d.promise();
}
/**
* Allows access to the deferred that manages the node connection. This
* is *only* for unit tests. Messing with this not in testing will
* potentially break everything.
*
* @private
* @return {jQuery.Deferred} The deferred that manages the node connection
*/
function _getNodeConnectionDeferred() {
return _nodeConnectionDeferred;
}
// Initializes node connection
// TODO: duplicates code from StaticServer
// TODO: can this be done lazily?
AppInit.appReady(function () {
_nodeConnection = new NodeConnection();
_nodeConnection.connect(true).then(function () {
var domainPath = FileUtils.getNativeBracketsDirectoryPath() + "/" + FileUtils.getNativeModuleDirectoryPath(module) + "/node/ExtensionManagerDomain";
_nodeConnection.loadDomains(domainPath, true)
.then(
function () {
_nodeConnectionDeferred.resolve();
},
function () { // Failed to connect
console.error("[Extensions] Failed to connect to node", arguments);
_nodeConnectionDeferred.reject();
}
);
});
});
// For unit tests only
exports._getNodeConnectionDeferred = _getNodeConnectionDeferred;
exports.installFromURL = installFromURL;
exports.installFromPath = installFromPath;
exports.validate = validate;
exports.install = install;
exports.remove = remove;
exports.disable = disable;
exports.enable = enable;
exports.installUpdate = installUpdate;
exports.formatError = formatError;
exports.InstallationStatuses = InstallationStatuses;
});