1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/services/sync/modules/addonutils.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,474 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +"use strict"; 1.9 + 1.10 +this.EXPORTED_SYMBOLS = ["AddonUtils"]; 1.11 + 1.12 +const {interfaces: Ci, utils: Cu} = Components; 1.13 + 1.14 +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); 1.15 +Cu.import("resource://gre/modules/Log.jsm"); 1.16 +Cu.import("resource://services-sync/util.js"); 1.17 + 1.18 +XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", 1.19 + "resource://gre/modules/AddonManager.jsm"); 1.20 +XPCOMUtils.defineLazyModuleGetter(this, "AddonRepository", 1.21 + "resource://gre/modules/addons/AddonRepository.jsm"); 1.22 + 1.23 +function AddonUtilsInternal() { 1.24 + this._log = Log.repository.getLogger("Sync.AddonUtils"); 1.25 + this._log.Level = Log.Level[Svc.Prefs.get("log.logger.addonutils")]; 1.26 +} 1.27 +AddonUtilsInternal.prototype = { 1.28 + /** 1.29 + * Obtain an AddonInstall object from an AddonSearchResult instance. 1.30 + * 1.31 + * The callback will be invoked with the result of the operation. The 1.32 + * callback receives 2 arguments, error and result. Error will be falsy 1.33 + * on success or some kind of error value otherwise. The result argument 1.34 + * will be an AddonInstall on success or null on failure. It is possible 1.35 + * for the error to be falsy but result to be null. This could happen if 1.36 + * an install was not found. 1.37 + * 1.38 + * @param addon 1.39 + * AddonSearchResult to obtain install from. 1.40 + * @param cb 1.41 + * Function to be called with result of operation. 1.42 + */ 1.43 + getInstallFromSearchResult: 1.44 + function getInstallFromSearchResult(addon, cb, requireSecureURI=true) { 1.45 + 1.46 + this._log.debug("Obtaining install for " + addon.id); 1.47 + 1.48 + // Verify that the source URI uses TLS. We don't allow installs from 1.49 + // insecure sources for security reasons. The Addon Manager ensures that 1.50 + // cert validation, etc is performed. 1.51 + if (requireSecureURI) { 1.52 + let scheme = addon.sourceURI.scheme; 1.53 + if (scheme != "https") { 1.54 + cb(new Error("Insecure source URI scheme: " + scheme), addon.install); 1.55 + return; 1.56 + } 1.57 + } 1.58 + 1.59 + // We should theoretically be able to obtain (and use) addon.install if 1.60 + // it is available. However, the addon.sourceURI rewriting won't be 1.61 + // reflected in the AddonInstall, so we can't use it. If we ever get rid 1.62 + // of sourceURI rewriting, we can avoid having to reconstruct the 1.63 + // AddonInstall. 1.64 + AddonManager.getInstallForURL( 1.65 + addon.sourceURI.spec, 1.66 + function handleInstall(install) { 1.67 + cb(null, install); 1.68 + }, 1.69 + "application/x-xpinstall", 1.70 + undefined, 1.71 + addon.name, 1.72 + addon.iconURL, 1.73 + addon.version 1.74 + ); 1.75 + }, 1.76 + 1.77 + /** 1.78 + * Installs an add-on from an AddonSearchResult instance. 1.79 + * 1.80 + * The options argument defines extra options to control the install. 1.81 + * Recognized keys in this map are: 1.82 + * 1.83 + * syncGUID - Sync GUID to use for the new add-on. 1.84 + * enabled - Boolean indicating whether the add-on should be enabled upon 1.85 + * install. 1.86 + * requireSecureURI - Boolean indicating whether to require a secure 1.87 + * URI to install from. This defaults to true. 1.88 + * 1.89 + * When complete it calls a callback with 2 arguments, error and result. 1.90 + * 1.91 + * If error is falsy, result is an object. If error is truthy, result is 1.92 + * null. 1.93 + * 1.94 + * The result object has the following keys: 1.95 + * 1.96 + * id ID of add-on that was installed. 1.97 + * install AddonInstall that was installed. 1.98 + * addon Addon that was installed. 1.99 + * 1.100 + * @param addon 1.101 + * AddonSearchResult to install add-on from. 1.102 + * @param options 1.103 + * Object with additional metadata describing how to install add-on. 1.104 + * @param cb 1.105 + * Function to be invoked with result of operation. 1.106 + */ 1.107 + installAddonFromSearchResult: 1.108 + function installAddonFromSearchResult(addon, options, cb) { 1.109 + this._log.info("Trying to install add-on from search result: " + addon.id); 1.110 + 1.111 + if (options.requireSecureURI === undefined) { 1.112 + options.requireSecureURI = true; 1.113 + } 1.114 + 1.115 + this.getInstallFromSearchResult(addon, function onResult(error, install) { 1.116 + if (error) { 1.117 + cb(error, null); 1.118 + return; 1.119 + } 1.120 + 1.121 + if (!install) { 1.122 + cb(new Error("AddonInstall not available: " + addon.id), null); 1.123 + return; 1.124 + } 1.125 + 1.126 + try { 1.127 + this._log.info("Installing " + addon.id); 1.128 + let log = this._log; 1.129 + 1.130 + let listener = { 1.131 + onInstallStarted: function onInstallStarted(install) { 1.132 + if (!options) { 1.133 + return; 1.134 + } 1.135 + 1.136 + if (options.syncGUID) { 1.137 + log.info("Setting syncGUID of " + install.name +": " + 1.138 + options.syncGUID); 1.139 + install.addon.syncGUID = options.syncGUID; 1.140 + } 1.141 + 1.142 + // We only need to change userDisabled if it is disabled because 1.143 + // enabled is the default. 1.144 + if ("enabled" in options && !options.enabled) { 1.145 + log.info("Marking add-on as disabled for install: " + 1.146 + install.name); 1.147 + install.addon.userDisabled = true; 1.148 + } 1.149 + }, 1.150 + onInstallEnded: function(install, addon) { 1.151 + install.removeListener(listener); 1.152 + 1.153 + cb(null, {id: addon.id, install: install, addon: addon}); 1.154 + }, 1.155 + onInstallFailed: function(install) { 1.156 + install.removeListener(listener); 1.157 + 1.158 + cb(new Error("Install failed: " + install.error), null); 1.159 + }, 1.160 + onDownloadFailed: function(install) { 1.161 + install.removeListener(listener); 1.162 + 1.163 + cb(new Error("Download failed: " + install.error), null); 1.164 + } 1.165 + }; 1.166 + install.addListener(listener); 1.167 + install.install(); 1.168 + } 1.169 + catch (ex) { 1.170 + this._log.error("Error installing add-on: " + Utils.exceptionstr(ex)); 1.171 + cb(ex, null); 1.172 + } 1.173 + }.bind(this), options.requireSecureURI); 1.174 + }, 1.175 + 1.176 + /** 1.177 + * Uninstalls the Addon instance and invoke a callback when it is done. 1.178 + * 1.179 + * @param addon 1.180 + * Addon instance to uninstall. 1.181 + * @param cb 1.182 + * Function to be invoked when uninstall has finished. It receives a 1.183 + * truthy value signifying error and the add-on which was uninstalled. 1.184 + */ 1.185 + uninstallAddon: function uninstallAddon(addon, cb) { 1.186 + let listener = { 1.187 + onUninstalling: function(uninstalling, needsRestart) { 1.188 + if (addon.id != uninstalling.id) { 1.189 + return; 1.190 + } 1.191 + 1.192 + // We assume restartless add-ons will send the onUninstalled event 1.193 + // soon. 1.194 + if (!needsRestart) { 1.195 + return; 1.196 + } 1.197 + 1.198 + // For non-restartless add-ons, we issue the callback on uninstalling 1.199 + // because we will likely never see the uninstalled event. 1.200 + AddonManager.removeAddonListener(listener); 1.201 + cb(null, addon); 1.202 + }, 1.203 + onUninstalled: function(uninstalled) { 1.204 + if (addon.id != uninstalled.id) { 1.205 + return; 1.206 + } 1.207 + 1.208 + AddonManager.removeAddonListener(listener); 1.209 + cb(null, addon); 1.210 + } 1.211 + }; 1.212 + AddonManager.addAddonListener(listener); 1.213 + addon.uninstall(); 1.214 + }, 1.215 + 1.216 + /** 1.217 + * Installs multiple add-ons specified by metadata. 1.218 + * 1.219 + * The first argument is an array of objects. Each object must have the 1.220 + * following keys: 1.221 + * 1.222 + * id - public ID of the add-on to install. 1.223 + * syncGUID - syncGUID for new add-on. 1.224 + * enabled - boolean indicating whether the add-on should be enabled. 1.225 + * requireSecureURI - Boolean indicating whether to require a secure 1.226 + * URI when installing from a remote location. This defaults to 1.227 + * true. 1.228 + * 1.229 + * The callback will be called when activity on all add-ons is complete. The 1.230 + * callback receives 2 arguments, error and result. 1.231 + * 1.232 + * If error is truthy, it contains a string describing the overall error. 1.233 + * 1.234 + * The 2nd argument to the callback is always an object with details on the 1.235 + * overall execution state. It contains the following keys: 1.236 + * 1.237 + * installedIDs Array of add-on IDs that were installed. 1.238 + * installs Array of AddonInstall instances that were installed. 1.239 + * addons Array of Addon instances that were installed. 1.240 + * errors Array of errors encountered. Only has elements if error is 1.241 + * truthy. 1.242 + * 1.243 + * @param installs 1.244 + * Array of objects describing add-ons to install. 1.245 + * @param cb 1.246 + * Function to be called when all actions are complete. 1.247 + */ 1.248 + installAddons: function installAddons(installs, cb) { 1.249 + if (!cb) { 1.250 + throw new Error("Invalid argument: cb is not defined."); 1.251 + } 1.252 + 1.253 + let ids = []; 1.254 + for each (let addon in installs) { 1.255 + ids.push(addon.id); 1.256 + } 1.257 + 1.258 + AddonRepository.getAddonsByIDs(ids, { 1.259 + searchSucceeded: function searchSucceeded(addons, addonsLength, total) { 1.260 + this._log.info("Found " + addonsLength + "/" + ids.length + 1.261 + " add-ons during repository search."); 1.262 + 1.263 + let ourResult = { 1.264 + installedIDs: [], 1.265 + installs: [], 1.266 + addons: [], 1.267 + errors: [] 1.268 + }; 1.269 + 1.270 + if (!addonsLength) { 1.271 + cb(null, ourResult); 1.272 + return; 1.273 + } 1.274 + 1.275 + let expectedInstallCount = 0; 1.276 + let finishedCount = 0; 1.277 + let installCallback = function installCallback(error, result) { 1.278 + finishedCount++; 1.279 + 1.280 + if (error) { 1.281 + ourResult.errors.push(error); 1.282 + } else { 1.283 + ourResult.installedIDs.push(result.id); 1.284 + ourResult.installs.push(result.install); 1.285 + ourResult.addons.push(result.addon); 1.286 + } 1.287 + 1.288 + if (finishedCount >= expectedInstallCount) { 1.289 + if (ourResult.errors.length > 0) { 1.290 + cb(new Error("1 or more add-ons failed to install"), ourResult); 1.291 + } else { 1.292 + cb(null, ourResult); 1.293 + } 1.294 + } 1.295 + }.bind(this); 1.296 + 1.297 + let toInstall = []; 1.298 + 1.299 + // Rewrite the "src" query string parameter of the source URI to note 1.300 + // that the add-on was installed by Sync and not something else so 1.301 + // server-side metrics aren't skewed (bug 708134). The server should 1.302 + // ideally send proper URLs, but this solution was deemed too 1.303 + // complicated at the time the functionality was implemented. 1.304 + for each (let addon in addons) { 1.305 + // sourceURI presence isn't enforced by AddonRepository. So, we skip 1.306 + // add-ons without a sourceURI. 1.307 + if (!addon.sourceURI) { 1.308 + this._log.info("Skipping install of add-on because missing " + 1.309 + "sourceURI: " + addon.id); 1.310 + continue; 1.311 + } 1.312 + 1.313 + toInstall.push(addon); 1.314 + 1.315 + // We should always be able to QI the nsIURI to nsIURL. If not, we 1.316 + // still try to install the add-on, but we don't rewrite the URL, 1.317 + // potentially skewing metrics. 1.318 + try { 1.319 + addon.sourceURI.QueryInterface(Ci.nsIURL); 1.320 + } catch (ex) { 1.321 + this._log.warn("Unable to QI sourceURI to nsIURL: " + 1.322 + addon.sourceURI.spec); 1.323 + continue; 1.324 + } 1.325 + 1.326 + let params = addon.sourceURI.query.split("&").map( 1.327 + function rewrite(param) { 1.328 + 1.329 + if (param.indexOf("src=") == 0) { 1.330 + return "src=sync"; 1.331 + } else { 1.332 + return param; 1.333 + } 1.334 + }); 1.335 + 1.336 + addon.sourceURI.query = params.join("&"); 1.337 + } 1.338 + 1.339 + expectedInstallCount = toInstall.length; 1.340 + 1.341 + if (!expectedInstallCount) { 1.342 + cb(null, ourResult); 1.343 + return; 1.344 + } 1.345 + 1.346 + // Start all the installs asynchronously. They will report back to us 1.347 + // as they finish, eventually triggering the global callback. 1.348 + for each (let addon in toInstall) { 1.349 + let options = {}; 1.350 + for each (let install in installs) { 1.351 + if (install.id == addon.id) { 1.352 + options = install; 1.353 + break; 1.354 + } 1.355 + } 1.356 + 1.357 + this.installAddonFromSearchResult(addon, options, installCallback); 1.358 + } 1.359 + 1.360 + }.bind(this), 1.361 + 1.362 + searchFailed: function searchFailed() { 1.363 + cb(new Error("AddonRepository search failed"), null); 1.364 + }, 1.365 + }); 1.366 + }, 1.367 + 1.368 + /** 1.369 + * Update the user disabled flag for an add-on. 1.370 + * 1.371 + * The supplied callback will ba called when the operation is 1.372 + * complete. If the new flag matches the existing or if the add-on 1.373 + * isn't currently active, the function will fire the callback 1.374 + * immediately. Else, the callback is invoked when the AddonManager 1.375 + * reports the change has taken effect or has been registered. 1.376 + * 1.377 + * The callback receives as arguments: 1.378 + * 1.379 + * (Error) Encountered error during operation or null on success. 1.380 + * (Addon) The add-on instance being operated on. 1.381 + * 1.382 + * @param addon 1.383 + * (Addon) Add-on instance to operate on. 1.384 + * @param value 1.385 + * (bool) New value for add-on's userDisabled property. 1.386 + * @param cb 1.387 + * (function) Callback to be invoked on completion. 1.388 + */ 1.389 + updateUserDisabled: function updateUserDisabled(addon, value, cb) { 1.390 + if (addon.userDisabled == value) { 1.391 + cb(null, addon); 1.392 + return; 1.393 + } 1.394 + 1.395 + let listener = { 1.396 + onEnabling: function onEnabling(wrapper, needsRestart) { 1.397 + this._log.debug("onEnabling: " + wrapper.id); 1.398 + if (wrapper.id != addon.id) { 1.399 + return; 1.400 + } 1.401 + 1.402 + // We ignore the restartless case because we'll get onEnabled shortly. 1.403 + if (!needsRestart) { 1.404 + return; 1.405 + } 1.406 + 1.407 + AddonManager.removeAddonListener(listener); 1.408 + cb(null, wrapper); 1.409 + }.bind(this), 1.410 + 1.411 + onEnabled: function onEnabled(wrapper) { 1.412 + this._log.debug("onEnabled: " + wrapper.id); 1.413 + if (wrapper.id != addon.id) { 1.414 + return; 1.415 + } 1.416 + 1.417 + AddonManager.removeAddonListener(listener); 1.418 + cb(null, wrapper); 1.419 + }.bind(this), 1.420 + 1.421 + onDisabling: function onDisabling(wrapper, needsRestart) { 1.422 + this._log.debug("onDisabling: " + wrapper.id); 1.423 + if (wrapper.id != addon.id) { 1.424 + return; 1.425 + } 1.426 + 1.427 + if (!needsRestart) { 1.428 + return; 1.429 + } 1.430 + 1.431 + AddonManager.removeAddonListener(listener); 1.432 + cb(null, wrapper); 1.433 + }.bind(this), 1.434 + 1.435 + onDisabled: function onDisabled(wrapper) { 1.436 + this._log.debug("onDisabled: " + wrapper.id); 1.437 + if (wrapper.id != addon.id) { 1.438 + return; 1.439 + } 1.440 + 1.441 + AddonManager.removeAddonListener(listener); 1.442 + cb(null, wrapper); 1.443 + }.bind(this), 1.444 + 1.445 + onOperationCancelled: function onOperationCancelled(wrapper) { 1.446 + this._log.debug("onOperationCancelled: " + wrapper.id); 1.447 + if (wrapper.id != addon.id) { 1.448 + return; 1.449 + } 1.450 + 1.451 + AddonManager.removeAddonListener(listener); 1.452 + cb(new Error("Operation cancelled"), wrapper); 1.453 + }.bind(this) 1.454 + }; 1.455 + 1.456 + // The add-on listeners are only fired if the add-on is active. If not, the 1.457 + // change is silently updated and made active when/if the add-on is active. 1.458 + 1.459 + if (!addon.appDisabled) { 1.460 + AddonManager.addAddonListener(listener); 1.461 + } 1.462 + 1.463 + this._log.info("Updating userDisabled flag: " + addon.id + " -> " + value); 1.464 + addon.userDisabled = !!value; 1.465 + 1.466 + if (!addon.appDisabled) { 1.467 + cb(null, addon); 1.468 + return; 1.469 + } 1.470 + // Else the listener will handle invoking the callback. 1.471 + }, 1.472 + 1.473 +}; 1.474 + 1.475 +XPCOMUtils.defineLazyGetter(this, "AddonUtils", function() { 1.476 + return new AddonUtilsInternal(); 1.477 +});