michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: this.EXPORTED_SYMBOLS = ["AddonUtils"]; michael@0: michael@0: const {interfaces: Ci, utils: Cu} = Components; michael@0: michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: Cu.import("resource://gre/modules/Log.jsm"); michael@0: Cu.import("resource://services-sync/util.js"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", michael@0: "resource://gre/modules/AddonManager.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "AddonRepository", michael@0: "resource://gre/modules/addons/AddonRepository.jsm"); michael@0: michael@0: function AddonUtilsInternal() { michael@0: this._log = Log.repository.getLogger("Sync.AddonUtils"); michael@0: this._log.Level = Log.Level[Svc.Prefs.get("log.logger.addonutils")]; michael@0: } michael@0: AddonUtilsInternal.prototype = { michael@0: /** michael@0: * Obtain an AddonInstall object from an AddonSearchResult instance. michael@0: * michael@0: * The callback will be invoked with the result of the operation. The michael@0: * callback receives 2 arguments, error and result. Error will be falsy michael@0: * on success or some kind of error value otherwise. The result argument michael@0: * will be an AddonInstall on success or null on failure. It is possible michael@0: * for the error to be falsy but result to be null. This could happen if michael@0: * an install was not found. michael@0: * michael@0: * @param addon michael@0: * AddonSearchResult to obtain install from. michael@0: * @param cb michael@0: * Function to be called with result of operation. michael@0: */ michael@0: getInstallFromSearchResult: michael@0: function getInstallFromSearchResult(addon, cb, requireSecureURI=true) { michael@0: michael@0: this._log.debug("Obtaining install for " + addon.id); michael@0: michael@0: // Verify that the source URI uses TLS. We don't allow installs from michael@0: // insecure sources for security reasons. The Addon Manager ensures that michael@0: // cert validation, etc is performed. michael@0: if (requireSecureURI) { michael@0: let scheme = addon.sourceURI.scheme; michael@0: if (scheme != "https") { michael@0: cb(new Error("Insecure source URI scheme: " + scheme), addon.install); michael@0: return; michael@0: } michael@0: } michael@0: michael@0: // We should theoretically be able to obtain (and use) addon.install if michael@0: // it is available. However, the addon.sourceURI rewriting won't be michael@0: // reflected in the AddonInstall, so we can't use it. If we ever get rid michael@0: // of sourceURI rewriting, we can avoid having to reconstruct the michael@0: // AddonInstall. michael@0: AddonManager.getInstallForURL( michael@0: addon.sourceURI.spec, michael@0: function handleInstall(install) { michael@0: cb(null, install); michael@0: }, michael@0: "application/x-xpinstall", michael@0: undefined, michael@0: addon.name, michael@0: addon.iconURL, michael@0: addon.version michael@0: ); michael@0: }, michael@0: michael@0: /** michael@0: * Installs an add-on from an AddonSearchResult instance. michael@0: * michael@0: * The options argument defines extra options to control the install. michael@0: * Recognized keys in this map are: michael@0: * michael@0: * syncGUID - Sync GUID to use for the new add-on. michael@0: * enabled - Boolean indicating whether the add-on should be enabled upon michael@0: * install. michael@0: * requireSecureURI - Boolean indicating whether to require a secure michael@0: * URI to install from. This defaults to true. michael@0: * michael@0: * When complete it calls a callback with 2 arguments, error and result. michael@0: * michael@0: * If error is falsy, result is an object. If error is truthy, result is michael@0: * null. michael@0: * michael@0: * The result object has the following keys: michael@0: * michael@0: * id ID of add-on that was installed. michael@0: * install AddonInstall that was installed. michael@0: * addon Addon that was installed. michael@0: * michael@0: * @param addon michael@0: * AddonSearchResult to install add-on from. michael@0: * @param options michael@0: * Object with additional metadata describing how to install add-on. michael@0: * @param cb michael@0: * Function to be invoked with result of operation. michael@0: */ michael@0: installAddonFromSearchResult: michael@0: function installAddonFromSearchResult(addon, options, cb) { michael@0: this._log.info("Trying to install add-on from search result: " + addon.id); michael@0: michael@0: if (options.requireSecureURI === undefined) { michael@0: options.requireSecureURI = true; michael@0: } michael@0: michael@0: this.getInstallFromSearchResult(addon, function onResult(error, install) { michael@0: if (error) { michael@0: cb(error, null); michael@0: return; michael@0: } michael@0: michael@0: if (!install) { michael@0: cb(new Error("AddonInstall not available: " + addon.id), null); michael@0: return; michael@0: } michael@0: michael@0: try { michael@0: this._log.info("Installing " + addon.id); michael@0: let log = this._log; michael@0: michael@0: let listener = { michael@0: onInstallStarted: function onInstallStarted(install) { michael@0: if (!options) { michael@0: return; michael@0: } michael@0: michael@0: if (options.syncGUID) { michael@0: log.info("Setting syncGUID of " + install.name +": " + michael@0: options.syncGUID); michael@0: install.addon.syncGUID = options.syncGUID; michael@0: } michael@0: michael@0: // We only need to change userDisabled if it is disabled because michael@0: // enabled is the default. michael@0: if ("enabled" in options && !options.enabled) { michael@0: log.info("Marking add-on as disabled for install: " + michael@0: install.name); michael@0: install.addon.userDisabled = true; michael@0: } michael@0: }, michael@0: onInstallEnded: function(install, addon) { michael@0: install.removeListener(listener); michael@0: michael@0: cb(null, {id: addon.id, install: install, addon: addon}); michael@0: }, michael@0: onInstallFailed: function(install) { michael@0: install.removeListener(listener); michael@0: michael@0: cb(new Error("Install failed: " + install.error), null); michael@0: }, michael@0: onDownloadFailed: function(install) { michael@0: install.removeListener(listener); michael@0: michael@0: cb(new Error("Download failed: " + install.error), null); michael@0: } michael@0: }; michael@0: install.addListener(listener); michael@0: install.install(); michael@0: } michael@0: catch (ex) { michael@0: this._log.error("Error installing add-on: " + Utils.exceptionstr(ex)); michael@0: cb(ex, null); michael@0: } michael@0: }.bind(this), options.requireSecureURI); michael@0: }, michael@0: michael@0: /** michael@0: * Uninstalls the Addon instance and invoke a callback when it is done. michael@0: * michael@0: * @param addon michael@0: * Addon instance to uninstall. michael@0: * @param cb michael@0: * Function to be invoked when uninstall has finished. It receives a michael@0: * truthy value signifying error and the add-on which was uninstalled. michael@0: */ michael@0: uninstallAddon: function uninstallAddon(addon, cb) { michael@0: let listener = { michael@0: onUninstalling: function(uninstalling, needsRestart) { michael@0: if (addon.id != uninstalling.id) { michael@0: return; michael@0: } michael@0: michael@0: // We assume restartless add-ons will send the onUninstalled event michael@0: // soon. michael@0: if (!needsRestart) { michael@0: return; michael@0: } michael@0: michael@0: // For non-restartless add-ons, we issue the callback on uninstalling michael@0: // because we will likely never see the uninstalled event. michael@0: AddonManager.removeAddonListener(listener); michael@0: cb(null, addon); michael@0: }, michael@0: onUninstalled: function(uninstalled) { michael@0: if (addon.id != uninstalled.id) { michael@0: return; michael@0: } michael@0: michael@0: AddonManager.removeAddonListener(listener); michael@0: cb(null, addon); michael@0: } michael@0: }; michael@0: AddonManager.addAddonListener(listener); michael@0: addon.uninstall(); michael@0: }, michael@0: michael@0: /** michael@0: * Installs multiple add-ons specified by metadata. michael@0: * michael@0: * The first argument is an array of objects. Each object must have the michael@0: * following keys: michael@0: * michael@0: * id - public ID of the add-on to install. michael@0: * syncGUID - syncGUID for new add-on. michael@0: * enabled - boolean indicating whether the add-on should be enabled. michael@0: * requireSecureURI - Boolean indicating whether to require a secure michael@0: * URI when installing from a remote location. This defaults to michael@0: * true. michael@0: * michael@0: * The callback will be called when activity on all add-ons is complete. The michael@0: * callback receives 2 arguments, error and result. michael@0: * michael@0: * If error is truthy, it contains a string describing the overall error. michael@0: * michael@0: * The 2nd argument to the callback is always an object with details on the michael@0: * overall execution state. It contains the following keys: michael@0: * michael@0: * installedIDs Array of add-on IDs that were installed. michael@0: * installs Array of AddonInstall instances that were installed. michael@0: * addons Array of Addon instances that were installed. michael@0: * errors Array of errors encountered. Only has elements if error is michael@0: * truthy. michael@0: * michael@0: * @param installs michael@0: * Array of objects describing add-ons to install. michael@0: * @param cb michael@0: * Function to be called when all actions are complete. michael@0: */ michael@0: installAddons: function installAddons(installs, cb) { michael@0: if (!cb) { michael@0: throw new Error("Invalid argument: cb is not defined."); michael@0: } michael@0: michael@0: let ids = []; michael@0: for each (let addon in installs) { michael@0: ids.push(addon.id); michael@0: } michael@0: michael@0: AddonRepository.getAddonsByIDs(ids, { michael@0: searchSucceeded: function searchSucceeded(addons, addonsLength, total) { michael@0: this._log.info("Found " + addonsLength + "/" + ids.length + michael@0: " add-ons during repository search."); michael@0: michael@0: let ourResult = { michael@0: installedIDs: [], michael@0: installs: [], michael@0: addons: [], michael@0: errors: [] michael@0: }; michael@0: michael@0: if (!addonsLength) { michael@0: cb(null, ourResult); michael@0: return; michael@0: } michael@0: michael@0: let expectedInstallCount = 0; michael@0: let finishedCount = 0; michael@0: let installCallback = function installCallback(error, result) { michael@0: finishedCount++; michael@0: michael@0: if (error) { michael@0: ourResult.errors.push(error); michael@0: } else { michael@0: ourResult.installedIDs.push(result.id); michael@0: ourResult.installs.push(result.install); michael@0: ourResult.addons.push(result.addon); michael@0: } michael@0: michael@0: if (finishedCount >= expectedInstallCount) { michael@0: if (ourResult.errors.length > 0) { michael@0: cb(new Error("1 or more add-ons failed to install"), ourResult); michael@0: } else { michael@0: cb(null, ourResult); michael@0: } michael@0: } michael@0: }.bind(this); michael@0: michael@0: let toInstall = []; michael@0: michael@0: // Rewrite the "src" query string parameter of the source URI to note michael@0: // that the add-on was installed by Sync and not something else so michael@0: // server-side metrics aren't skewed (bug 708134). The server should michael@0: // ideally send proper URLs, but this solution was deemed too michael@0: // complicated at the time the functionality was implemented. michael@0: for each (let addon in addons) { michael@0: // sourceURI presence isn't enforced by AddonRepository. So, we skip michael@0: // add-ons without a sourceURI. michael@0: if (!addon.sourceURI) { michael@0: this._log.info("Skipping install of add-on because missing " + michael@0: "sourceURI: " + addon.id); michael@0: continue; michael@0: } michael@0: michael@0: toInstall.push(addon); michael@0: michael@0: // We should always be able to QI the nsIURI to nsIURL. If not, we michael@0: // still try to install the add-on, but we don't rewrite the URL, michael@0: // potentially skewing metrics. michael@0: try { michael@0: addon.sourceURI.QueryInterface(Ci.nsIURL); michael@0: } catch (ex) { michael@0: this._log.warn("Unable to QI sourceURI to nsIURL: " + michael@0: addon.sourceURI.spec); michael@0: continue; michael@0: } michael@0: michael@0: let params = addon.sourceURI.query.split("&").map( michael@0: function rewrite(param) { michael@0: michael@0: if (param.indexOf("src=") == 0) { michael@0: return "src=sync"; michael@0: } else { michael@0: return param; michael@0: } michael@0: }); michael@0: michael@0: addon.sourceURI.query = params.join("&"); michael@0: } michael@0: michael@0: expectedInstallCount = toInstall.length; michael@0: michael@0: if (!expectedInstallCount) { michael@0: cb(null, ourResult); michael@0: return; michael@0: } michael@0: michael@0: // Start all the installs asynchronously. They will report back to us michael@0: // as they finish, eventually triggering the global callback. michael@0: for each (let addon in toInstall) { michael@0: let options = {}; michael@0: for each (let install in installs) { michael@0: if (install.id == addon.id) { michael@0: options = install; michael@0: break; michael@0: } michael@0: } michael@0: michael@0: this.installAddonFromSearchResult(addon, options, installCallback); michael@0: } michael@0: michael@0: }.bind(this), michael@0: michael@0: searchFailed: function searchFailed() { michael@0: cb(new Error("AddonRepository search failed"), null); michael@0: }, michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Update the user disabled flag for an add-on. michael@0: * michael@0: * The supplied callback will ba called when the operation is michael@0: * complete. If the new flag matches the existing or if the add-on michael@0: * isn't currently active, the function will fire the callback michael@0: * immediately. Else, the callback is invoked when the AddonManager michael@0: * reports the change has taken effect or has been registered. michael@0: * michael@0: * The callback receives as arguments: michael@0: * michael@0: * (Error) Encountered error during operation or null on success. michael@0: * (Addon) The add-on instance being operated on. michael@0: * michael@0: * @param addon michael@0: * (Addon) Add-on instance to operate on. michael@0: * @param value michael@0: * (bool) New value for add-on's userDisabled property. michael@0: * @param cb michael@0: * (function) Callback to be invoked on completion. michael@0: */ michael@0: updateUserDisabled: function updateUserDisabled(addon, value, cb) { michael@0: if (addon.userDisabled == value) { michael@0: cb(null, addon); michael@0: return; michael@0: } michael@0: michael@0: let listener = { michael@0: onEnabling: function onEnabling(wrapper, needsRestart) { michael@0: this._log.debug("onEnabling: " + wrapper.id); michael@0: if (wrapper.id != addon.id) { michael@0: return; michael@0: } michael@0: michael@0: // We ignore the restartless case because we'll get onEnabled shortly. michael@0: if (!needsRestart) { michael@0: return; michael@0: } michael@0: michael@0: AddonManager.removeAddonListener(listener); michael@0: cb(null, wrapper); michael@0: }.bind(this), michael@0: michael@0: onEnabled: function onEnabled(wrapper) { michael@0: this._log.debug("onEnabled: " + wrapper.id); michael@0: if (wrapper.id != addon.id) { michael@0: return; michael@0: } michael@0: michael@0: AddonManager.removeAddonListener(listener); michael@0: cb(null, wrapper); michael@0: }.bind(this), michael@0: michael@0: onDisabling: function onDisabling(wrapper, needsRestart) { michael@0: this._log.debug("onDisabling: " + wrapper.id); michael@0: if (wrapper.id != addon.id) { michael@0: return; michael@0: } michael@0: michael@0: if (!needsRestart) { michael@0: return; michael@0: } michael@0: michael@0: AddonManager.removeAddonListener(listener); michael@0: cb(null, wrapper); michael@0: }.bind(this), michael@0: michael@0: onDisabled: function onDisabled(wrapper) { michael@0: this._log.debug("onDisabled: " + wrapper.id); michael@0: if (wrapper.id != addon.id) { michael@0: return; michael@0: } michael@0: michael@0: AddonManager.removeAddonListener(listener); michael@0: cb(null, wrapper); michael@0: }.bind(this), michael@0: michael@0: onOperationCancelled: function onOperationCancelled(wrapper) { michael@0: this._log.debug("onOperationCancelled: " + wrapper.id); michael@0: if (wrapper.id != addon.id) { michael@0: return; michael@0: } michael@0: michael@0: AddonManager.removeAddonListener(listener); michael@0: cb(new Error("Operation cancelled"), wrapper); michael@0: }.bind(this) michael@0: }; michael@0: michael@0: // The add-on listeners are only fired if the add-on is active. If not, the michael@0: // change is silently updated and made active when/if the add-on is active. michael@0: michael@0: if (!addon.appDisabled) { michael@0: AddonManager.addAddonListener(listener); michael@0: } michael@0: michael@0: this._log.info("Updating userDisabled flag: " + addon.id + " -> " + value); michael@0: addon.userDisabled = !!value; michael@0: michael@0: if (!addon.appDisabled) { michael@0: cb(null, addon); michael@0: return; michael@0: } michael@0: // Else the listener will handle invoking the callback. michael@0: }, michael@0: michael@0: }; michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "AddonUtils", function() { michael@0: return new AddonUtilsInternal(); michael@0: });