The ExtensionManager fetches/caches the extension registry and provides information about the status of installed extensions. ExtensionManager raises the following events:
Extension status constants.
var ENABLED = "enabled",
DISABLED = "disabled",
START_FAILED = "startFailed";
Extension auto-install folder. Also used for preferences key.
var FOLDER_AUTOINSTALL = "auto-install-extensions";
Extension location constants.
var LOCATION_DEFAULT = "default",
LOCATION_DEV = "dev",
LOCATION_USER = "user",
LOCATION_UNKNOWN = "unknown";
Requested changes to the installed extensions.
var _idsToRemove = {},
_idsToUpdate = {},
_idsToDisable = {};
PreferencesManager.stateManager.definePreference(FOLDER_AUTOINSTALL, "object", undefined);
PreferencesManager.definePreference("extensions.sort", "string", "publishedDate", {
description: Strings.SORT_EXTENSION_METHOD
});
function _autoInstallExtensions() {
var dirPath = FileUtils.getDirectoryPath(FileUtils.getNativeBracketsDirectoryPath()) + FOLDER_AUTOINSTALL + "/",
autoExtensions = PreferencesManager.getViewState(FOLDER_AUTOINSTALL) || {},
deferred = new $.Deferred();
_getAutoInstallFiles(dirPath, autoExtensions).done(function (result) {
var installPromise = Async.doSequentially(result.installZips, function (zip) {
autoExtensions[zip.info.metadata.name] = zip.info.metadata.version;
return Package.installFromPath(zip.file.fullPath);
});
var updatePromise = installPromise.always(function () {
return Async.doSequentially(result.updateZips, function (zip) {
autoExtensions[zip.info.metadata.name] = zip.info.metadata.version;
return Package.installUpdate(zip.file.fullPath);
});
});
// Always resolve the outer promise
updatePromise.always(function () {
// Keep track of auto-installed extensions so we only install an extension once
PreferencesManager.setViewState(FOLDER_AUTOINSTALL, autoExtensions);
deferred.resolve();
});
});
return deferred.promise();
}
AppInit.appReady(function () {
Package._getNodeConnectionDeferred().done(function () {
_autoInstallExtensions();
});
});
// Listen to extension load and loadFailed events
ExtensionLoader
.on("load", _handleExtensionLoad)
.on("loadFailed", _handleExtensionLoad)
.on("disabled", _handleExtensionLoad);
EventDispatcher.makeEventDispatcher(exports);
// Public exports
exports.downloadRegistry = downloadRegistry;
exports.getCompatibilityInfo = getCompatibilityInfo;
exports.getExtensionURL = getExtensionURL;
exports.remove = remove;
exports.update = update;
exports.disable = disable;
exports.enable = enable;
exports.extensions = extensions;
exports.cleanupUpdates = cleanupUpdates;
exports.markForRemoval = markForRemoval;
exports.isMarkedForRemoval = isMarkedForRemoval;
exports.unmarkAllForRemoval = unmarkAllForRemoval;
exports.hasExtensionsToRemove = hasExtensionsToRemove;
exports.markForDisabling = markForDisabling;
exports.isMarkedForDisabling = isMarkedForDisabling;
exports.unmarkAllForDisabling = unmarkAllForDisabling;
exports.hasExtensionsToDisable = hasExtensionsToDisable;
exports.updateFromDownload = updateFromDownload;
exports.removeUpdate = removeUpdate;
exports.isMarkedForUpdate = isMarkedForUpdate;
exports.hasExtensionsToUpdate = hasExtensionsToUpdate;
exports.removeMarkedExtensions = removeMarkedExtensions;
exports.disableMarkedExtensions = disableMarkedExtensions;
exports.updateExtensions = updateExtensions;
exports.getAvailableUpdates = getAvailableUpdates;
exports.cleanAvailableUpdates = cleanAvailableUpdates;
exports.hasDownloadedRegistry = false;
exports.ENABLED = ENABLED;
exports.DISABLED = DISABLED;
exports.START_FAILED = START_FAILED;
exports.LOCATION_DEFAULT = LOCATION_DEFAULT;
exports.LOCATION_DEV = LOCATION_DEV;
exports.LOCATION_USER = LOCATION_USER;
exports.LOCATION_UNKNOWN = LOCATION_UNKNOWN;
// For unit testing only
exports._getAutoInstallFiles = _getAutoInstallFiles;
exports._reset = _reset;
exports._setExtensions = _setExtensions;
});
function _enableOrDisable(id, enable) {
var result = new $.Deferred(),
extension = extensions[id];
if (extension && extension.installInfo) {
Package[(enable ? "enable" : "disable")](extension.installInfo.path)
.done(function () {
extension.installInfo.status = enable ? ENABLED : DISABLED;
extension.installInfo.metadata.disabled = !enable;
result.resolve();
exports.trigger("statusChange", id);
})
.fail(function (err) {
result.reject(err);
});
} else {
result.reject(StringUtils.format(Strings.EXTENSION_NOT_INSTALLED, id));
}
return result.promise();
}
function _getAutoInstallFiles(dirPath, autoExtensions) {
var zipFiles = [],
installZips = [],
updateZips = [],
deferred = new $.Deferred();
FileSystem.getDirectoryForPath(dirPath).getContents(function (err, contents) {
if (!err) {
zipFiles = contents.filter(function (dirItem) {
return (dirItem.isFile && FileUtils.getFileExtension(dirItem.fullPath) === "zip");
});
}
// Parse zip files and separate new installs vs. updates
Async.doInParallel_aggregateErrors(zipFiles, function (file) {
var zipFilePromise = new $.Deferred();
// Call validate() so that we open the local zip file and parse the
// package.json. We need the name to detect if this zip will be a
// new install or an update.
Package.validate(file.fullPath, { requirePackageJSON: true }).done(function (info) {
if (info.errors.length) {
zipFilePromise.reject(Package.formatError(info.errors));
return;
}
var extensionInfo, installedVersion, zipArray, existingItem,
extensionName = info.metadata.name,
autoExtVersion = autoExtensions[extensionName];
// Verify extension has not already been auto-installed/updated
if (autoExtVersion && semver.lte(info.metadata.version, autoExtVersion)) {
// Have already auto installed/updated version >= version of this extension
zipFilePromise.reject();
return;
}
// Verify extension has not already been installed/updated by some other means
extensionInfo = extensions[extensionName];
installedVersion = extensionInfo && extensionInfo.installInfo && extensionInfo.installInfo.metadata.version;
if (installedVersion && semver.lte(info.metadata.version, installedVersion)) {
// Have already manually installed/updated version >= version of this extension
zipFilePromise.reject();
return;
}
// Update appropriate zip array. There could be multiple zip files for an
// extension, so make sure only the latest is stored
zipArray = (installedVersion) ? updateZips : installZips;
zipArray.some(function (zip) {
if (zip.info.metadata.name === extensionName) {
existingItem = zip;
return true;
}
return false;
});
if (existingItem) {
if (semver.lt(existingItem.info.metadata.version, info.metadata.version)) {
existingItem.file = file;
existingItem.info = info;
}
} else {
zipArray.push({ file: file, info: info });
}
zipFilePromise.resolve();
}).fail(function (err) {
zipFilePromise.reject(Package.formatError(err));
});
return zipFilePromise.promise();
}).fail(function (errorArray) {
// Async.doInParallel() fails if some are successful, so write errors
// to console and always resolve
errorArray.forEach(function (errorObj) {
// If we rejected without an error argument, it means it was no problem
// (e.g. same version of extension is already installed)
if (errorObj.error) {
if (errorObj.error.forEach) {
console.error("Errors for", errorObj.item);
errorObj.error.forEach(function (error) {
console.error(Package.formatError(error));
});
} else {
console.error("Error for", errorObj.item, errorObj);
}
}
});
}).always(function () {
deferred.resolve({
installZips: installZips,
updateZips: updateZips
});
});
});
return deferred.promise();
}
function _handleExtensionLoad(e, path) {
function setData(metadata) {
var locationType,
id = metadata.name,
userExtensionPath = ExtensionLoader.getUserExtensionPath();
if (path.indexOf(userExtensionPath) === 0) {
locationType = LOCATION_USER;
} else {
var segments = path.split("/"), parent;
if (segments.length > 2) {
parent = segments[segments.length - 2];
}
if (parent === "dev") {
locationType = LOCATION_DEV;
} else if (parent === "default") {
locationType = LOCATION_DEFAULT;
} else {
locationType = LOCATION_UNKNOWN;
}
}
if (!extensions[id]) {
extensions[id] = {};
}
extensions[id].installInfo = {
metadata: metadata,
path: path,
locationType: locationType,
status: (e.type === "loadFailed" ? START_FAILED : (e.type === "disabled" ? DISABLED : ENABLED))
};
synchronizeEntry(id);
loadTheme(id);
exports.trigger("statusChange", id);
}
function deduceMetadata() {
var match = path.match(/\/([^\/]+)$/),
name = (match && match[1]) || path,
metadata = { name: name, title: name };
return metadata;
}
ExtensionUtils.loadMetadata(path)
.done(function (metadata) {
setData(metadata);
})
.fail(function (disabled) {
// If there's no package.json, this is a legacy extension. It was successfully loaded,
// but we don't have an official ID or metadata for it, so we just create an id and
// "title" for it (which is the last segment of its pathname)
// and record that it's enabled.
var metadata = deduceMetadata();
metadata.disabled = disabled;
setData(metadata);
});
}
function _reset() {
exports.extensions = extensions = {};
_idsToRemove = {};
_idsToUpdate = {};
_idsToDisable = {};
}
function _setExtensions(newExtensions) {
exports.extensions = extensions = newExtensions;
Object.keys(extensions).forEach(function (id) {
synchronizeEntry(id);
});
}
Takes the array returned from getAvailableUpdates() as an input and removes those entries that are no longer current - when currently installed version of an extension is equal or newer than registryVersion returned by getAvailableUpdates(). This function is designed to work without the necessity to download extension registry
function cleanAvailableUpdates(updates) {
return updates.reduce(function (arr, updateInfo) {
var extDefinition = extensions[updateInfo.id];
if (!extDefinition || !extDefinition.installInfo) {
// extension has been uninstalled in the meantime
return arr;
}
var installedVersion = extDefinition.installInfo.metadata.version;
if (semver.lt(installedVersion, updateInfo.registryVersion)) {
arr.push(updateInfo);
}
return arr;
}, []);
}
Deletes any temporary files left behind by extensions that were marked for update.
function cleanupUpdates() {
Object.keys(_idsToUpdate).forEach(function (id) {
var installResult = _idsToUpdate[id],
keepFile = installResult.keepFile,
filename = installResult.localPath;
if (filename && !keepFile) {
FileSystem.getFileForPath(filename).unlink();
}
});
_idsToUpdate = {};
}
Disables the installed extension with the given id.
function disable(id) {
return _enableOrDisable(id, false);
}
Disables extensions marked for disabling.
If the return promise is rejected, the argument will contain an array of objects. Each element is an object identifying the extension failed with "item" property set to the extension id which has failed to be disabled and "error" property set to the error.
function disableMarkedExtensions() {
return Async.doInParallel_aggregateErrors(
Object.keys(_idsToDisable),
function (id) {
return disable(id);
}
);
}
Downloads the registry of Brackets extensions and stores the information in our extension info.
function downloadRegistry() {
if (pendingDownloadRegistry) {
return pendingDownloadRegistry.promise();
}
pendingDownloadRegistry = new $.Deferred();
$.ajax({
url: brackets.config.extension_registry,
dataType: "json",
cache: false
})
.done(function (data) {
exports.hasDownloadedRegistry = true;
Object.keys(data).forEach(function (id) {
if (!extensions[id]) {
extensions[id] = {};
}
extensions[id].registryInfo = data[id];
synchronizeEntry(id);
});
exports.trigger("registryDownload");
pendingDownloadRegistry.resolve();
})
.fail(function () {
pendingDownloadRegistry.reject();
})
.always(function () {
// Make sure to clean up the pending registry so that new requests can be made.
pendingDownloadRegistry = null;
});
return pendingDownloadRegistry.promise();
}
Enables the installed extension with the given id.
function enable(id) {
return _enableOrDisable(id, true);
}
Gets an array of extensions that are currently installed and can be updated to a new version
function getAvailableUpdates() {
var result = [];
Object.keys(extensions).forEach(function (extensionId) {
var extensionInfo = extensions[extensionId];
// skip extensions that are not installed or are not in the registry
if (!extensionInfo.installInfo || !extensionInfo.registryInfo) {
return;
}
if (extensionInfo.registryInfo.updateCompatible) {
result.push({
id: extensionId,
installVersion: extensionInfo.installInfo.metadata.version,
registryVersion: extensionInfo.registryInfo.lastCompatibleVersion
});
}
});
return result;
}
Finds the newest version of the entry that is compatible with the given Brackets API version, if any.
function getCompatibilityInfo(entry, apiVersion) {
if (!entry.versions) {
var fallback = getCompatibilityInfoForVersion(entry.metadata, apiVersion);
if (fallback.isCompatible) {
fallback.isLatestVersion = true;
}
return fallback;
}
var i = entry.versions.length - 1,
latestInfo = getCompatibilityInfoForVersion(entry.versions[i], apiVersion);
if (latestInfo.isCompatible) {
latestInfo.isLatestVersion = true;
return latestInfo;
} else {
// Look at earlier versions (skipping very latest version since we already checked it)
for (i--; i >= 0; i--) {
var compatInfo = getCompatibilityInfoForVersion(entry.versions[i], apiVersion);
if (compatInfo.isCompatible) {
compatInfo.isLatestVersion = false;
compatInfo.requiresNewer = latestInfo.requiresNewer;
return compatInfo;
}
}
// No version is compatible, so just return info for the latest version
return latestInfo;
}
}
Determines if the given versions[] entry is compatible with the given Brackets API version, and if not specifies why.
function getCompatibilityInfoForVersion(extVersion, apiVersion) {
var requiredVersion = (extVersion.brackets || (extVersion.engines && extVersion.engines.brackets)),
result = {};
result.isCompatible = !requiredVersion || semver.satisfies(apiVersion, requiredVersion);
if (result.isCompatible) {
result.compatibleVersion = extVersion.version;
} else {
// Find out reason for incompatibility
if (requiredVersion.charAt(0) === '<') {
result.requiresNewer = false;
} else if (requiredVersion.charAt(0) === '>') {
result.requiresNewer = true;
} else if (requiredVersion.charAt(0) === "~") {
var compareVersion = requiredVersion.slice(1);
// Need to add .0s to this style of range in order to compare (since valid version
// numbers must have major/minor/patch).
if (compareVersion.match(/^[0-9]+$/)) {
compareVersion += ".0.0";
} else if (compareVersion.match(/^[0-9]+\.[0-9]+$/)) {
compareVersion += ".0";
}
result.requiresNewer = semver.lt(apiVersion, compareVersion);
}
}
return result;
}
Given an extension id and version number, returns the URL for downloading that extension from the repository. Does not guarantee that the extension exists at that URL.
function getExtensionURL(id, version) {
return StringUtils.format(brackets.config.extension_url, id, version);
}
Returns true if there are any extensions marked for disabling.
function hasExtensionsToDisable() {
return Object.keys(_idsToDisable).length > 0;
}
Returns true if there are any extensions marked for removal.
function hasExtensionsToRemove() {
return Object.keys(_idsToRemove).length > 0;
}
Returns true if there are any extensions marked for update.
function hasExtensionsToUpdate() {
return Object.keys(_idsToUpdate).length > 0;
}
Returns true if an extension is mark for disabling.
function isMarkedForDisabling(id) {
return !!(_idsToDisable[id]);
}
Returns true if an extension is marked for removal.
function isMarkedForRemoval(id) {
return !!(_idsToRemove[id]);
}
Returns true if an extension is marked for update.
function isMarkedForUpdate(id) {
return !!(_idsToUpdate[id]);
}
function loadTheme(id) {
var extension = extensions[id];
if (extension.installInfo && extension.installInfo.metadata && extension.installInfo.metadata.theme) {
ThemeManager.loadPackage(extension.installInfo);
}
}
Marks an extension for disabling later, or unmarks an extension previously marked.
function markForDisabling(id, mark) {
if (mark) {
_idsToDisable[id] = true;
} else {
delete _idsToDisable[id];
}
exports.trigger("statusChange", id);
}
Marks an extension for later removal, or unmarks an extension previously marked.
function markForRemoval(id, mark) {
if (mark) {
_idsToRemove[id] = true;
} else {
delete _idsToRemove[id];
}
exports.trigger("statusChange", id);
}
Removes the installed extension with the given id.
function remove(id) {
var result = new $.Deferred();
if (extensions[id] && extensions[id].installInfo) {
Package.remove(extensions[id].installInfo.path)
.done(function () {
extensions[id].installInfo = null;
result.resolve();
exports.trigger("statusChange", id);
})
.fail(function (err) {
result.reject(err);
});
} else {
result.reject(StringUtils.format(Strings.EXTENSION_NOT_INSTALLED, id));
}
return result.promise();
}
Removes extensions previously marked for removal.
function removeMarkedExtensions() {
return Async.doInParallel_aggregateErrors(
Object.keys(_idsToRemove),
function (id) {
return remove(id);
}
);
}
Removes the mark for an extension to be updated on restart. Also deletes the downloaded package file.
function removeUpdate(id) {
var installationResult = _idsToUpdate[id];
if (!installationResult) {
return;
}
if (installationResult.localPath && !installationResult.keepFile) {
FileSystem.getFileForPath(installationResult.localPath).unlink();
}
delete _idsToUpdate[id];
exports.trigger("statusChange", id);
}
function synchronizeEntry(id) {
var entry = extensions[id];
// Do nothing if we only have one set of data
if (!entry || !entry.installInfo || !entry.registryInfo) {
return;
}
entry.installInfo.owner = entry.registryInfo.owner;
// Assume false
entry.installInfo.updateAvailable = false;
entry.registryInfo.updateAvailable = false;
entry.installInfo.updateCompatible = false;
entry.registryInfo.updateCompatible = false;
var currentVersion = entry.installInfo.metadata ? entry.installInfo.metadata.version : null;
if (currentVersion && semver.lt(currentVersion, entry.registryInfo.metadata.version)) {
// Note: available update may still be incompatible; we check for this when rendering the Update button in ExtensionManagerView._renderItem()
entry.registryInfo.updateAvailable = true;
entry.installInfo.updateAvailable = true;
// Calculate updateCompatible to check if there's an update for current version of Brackets
var lastCompatibleVersionInfo = _.findLast(entry.registryInfo.versions, function (versionInfo) {
return !versionInfo.brackets || semver.satisfies(brackets.metadata.apiVersion, versionInfo.brackets);
});
if (lastCompatibleVersionInfo && lastCompatibleVersionInfo.version && semver.lt(currentVersion, lastCompatibleVersionInfo.version)) {
entry.installInfo.updateCompatible = true;
entry.registryInfo.updateCompatible = true;
entry.installInfo.lastCompatibleVersion = lastCompatibleVersionInfo.version;
entry.registryInfo.lastCompatibleVersion = lastCompatibleVersionInfo.version;
}
}
exports.trigger("registryUpdate", id);
}
Unmarks all the extensions that have been marked for disabling.
function unmarkAllForDisabling() {
_idsToDisable = {};
}
Unmarks all extensions marked for removal.
function unmarkAllForRemoval() {
_idsToRemove = {};
}
Updates an installed extension with the given package file.
function update(id, packagePath, keepFile) {
return Package.installUpdate(packagePath, id).done(function () {
if (!keepFile) {
FileSystem.getFileForPath(packagePath).unlink();
}
});
}
Updates extensions previously marked for update.
function updateExtensions() {
return Async.doInParallel_aggregateErrors(
Object.keys(_idsToUpdate),
function (id) {
var installationResult = _idsToUpdate[id];
return update(installationResult.name, installationResult.localPath, installationResult.keepFile);
}
);
}
If a downloaded package appears to be an update, mark the extension for update. If an extension was previously marked for removal, marking for update will turn off the removal mark.
function updateFromDownload(installationResult) {
if (installationResult.keepFile === undefined) {
installationResult.keepFile = false;
}
var installationStatus = installationResult.installationStatus;
if (installationStatus === Package.InstallationStatuses.ALREADY_INSTALLED ||
installationStatus === Package.InstallationStatuses.NEEDS_UPDATE ||
installationStatus === Package.InstallationStatuses.SAME_VERSION ||
installationStatus === Package.InstallationStatuses.OLDER_VERSION) {
var id = installationResult.name;
delete _idsToRemove[id];
_idsToUpdate[id] = installationResult;
exports.trigger("statusChange", id);
}
}