diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/mozapps/extensions/AddonManager.sys.mjs | 5233 |
1 files changed, 5233 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/AddonManager.sys.mjs b/toolkit/mozapps/extensions/AddonManager.sys.mjs new file mode 100644 index 0000000000..2ae57f0b52 --- /dev/null +++ b/toolkit/mozapps/extensions/AddonManager.sys.mjs @@ -0,0 +1,5233 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Cannot use Services.appinfo here, or else xpcshell-tests will blow up, as +// most tests later register different nsIAppInfo implementations, which +// wouldn't be reflected in Services.appinfo anymore, as the lazy getter +// underlying it would have been initialized if we used it here. +if ("@mozilla.org/xre/app-info;1" in Cc) { + // eslint-disable-next-line mozilla/use-services + let runtime = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime); + if (runtime.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) { + // Refuse to run in child processes. + throw new Error("You cannot use the AddonManager in child processes!"); + } +} + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const MOZ_COMPATIBILITY_NIGHTLY = ![ + "aurora", + "beta", + "release", + "esr", +].includes(AppConstants.MOZ_UPDATE_CHANNEL); + +const INTL_LOCALES_CHANGED = "intl:app-locales-changed"; + +const PREF_AMO_ABUSEREPORT = "extensions.abuseReport.amWebAPI.enabled"; +const PREF_BLOCKLIST_PINGCOUNTVERSION = "extensions.blocklist.pingCountVersion"; +const PREF_EM_UPDATE_ENABLED = "extensions.update.enabled"; +const PREF_EM_LAST_APP_VERSION = "extensions.lastAppVersion"; +const PREF_EM_LAST_PLATFORM_VERSION = "extensions.lastPlatformVersion"; +const PREF_EM_AUTOUPDATE_DEFAULT = "extensions.update.autoUpdateDefault"; +const PREF_EM_STRICT_COMPATIBILITY = "extensions.strictCompatibility"; +const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity"; +const PREF_SYS_ADDON_UPDATE_ENABLED = "extensions.systemAddon.update.enabled"; +const PREF_REMOTESETTINGS_DISABLED = "extensions.remoteSettings.disabled"; + +const PREF_MIN_WEBEXT_PLATFORM_VERSION = + "extensions.webExtensionsMinPlatformVersion"; +const PREF_WEBAPI_TESTING = "extensions.webapi.testing"; +const PREF_EM_POSTDOWNLOAD_THIRD_PARTY = + "extensions.postDownloadThirdPartyPrompt"; + +const UPDATE_REQUEST_VERSION = 2; + +const BRANCH_REGEXP = /^([^\.]+\.[0-9]+[a-z]*).*/gi; +const PREF_EM_CHECK_COMPATIBILITY_BASE = "extensions.checkCompatibility"; +var PREF_EM_CHECK_COMPATIBILITY = MOZ_COMPATIBILITY_NIGHTLY + ? PREF_EM_CHECK_COMPATIBILITY_BASE + ".nightly" + : undefined; + +const WEBAPI_INSTALL_HOSTS = + AppConstants.MOZ_APP_NAME !== "thunderbird" + ? ["addons.mozilla.org"] + : ["addons.thunderbird.net"]; +const WEBAPI_TEST_INSTALL_HOSTS = + AppConstants.MOZ_APP_NAME !== "thunderbird" + ? ["addons.allizom.org", "addons-dev.allizom.org", "example.com"] + : ["addons-stage.thunderbird.net", "example.com"]; + +const AMO_ATTRIBUTION_ALLOWED_SOURCES = ["amo", "disco", "rtamo"]; +const AMO_ATTRIBUTION_DATA_KEYS = [ + "utm_campaign", + "utm_content", + "utm_medium", + "utm_source", +]; +const AMO_ATTRIBUTION_DATA_MAX_LENGTH = 40; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +// This global is overridden by xpcshell tests, and therefore cannot be +// a const. +import { AsyncShutdown as realAsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs"; + +var AsyncShutdown = realAsyncShutdown; + +import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs", + AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs", + Extension: "resource://gre/modules/Extension.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + TelemetryTimestamps: "resource://gre/modules/TelemetryTimestamps.sys.mjs", + isGatedPermissionType: + "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs", + isKnownPublicSuffix: + "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs", + isPrincipalInSitePermissionsBlocklist: + "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "WEBEXT_POSTDOWNLOAD_THIRD_PARTY", + PREF_EM_POSTDOWNLOAD_THIRD_PARTY, + false +); + +// Initialize the WebExtension process script service as early as possible, +// since it needs to be able to track things like new frameLoader globals that +// are created before other framework code has been initialized. +Services.ppmm.loadProcessScript( + "resource://gre/modules/extensionProcessScriptLoader.js", + true +); + +const INTEGER = /^[1-9]\d*$/; + +const CATEGORY_PROVIDER_MODULE = "addon-provider-module"; + +import { Log } from "resource://gre/modules/Log.sys.mjs"; +// Configure a logger at the parent 'addons' level to format +// messages for all the modules under addons.* +const PARENT_LOGGER_ID = "addons"; +var parentLogger = Log.repository.getLogger(PARENT_LOGGER_ID); +parentLogger.level = Log.Level.Warn; +var formatter = new Log.BasicFormatter(); +// Set parent logger (and its children) to append to +// the Javascript section of the Browser Console +parentLogger.addAppender(new Log.ConsoleAppender(formatter)); + +// Create a new logger (child of 'addons' logger) +// for use by the Addons Manager +const LOGGER_ID = "addons.manager"; +var logger = Log.repository.getLogger(LOGGER_ID); + +// Provide the ability to enable/disable logging +// messages at runtime. +// If the "extensions.logging.enabled" preference is +// missing or 'false', messages at the WARNING and higher +// severity should be logged to the JS console and standard error. +// If "extensions.logging.enabled" is set to 'true', messages +// at DEBUG and higher should go to JS console and standard error. +const PREF_LOGGING_ENABLED = "extensions.logging.enabled"; +const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; + +const UNNAMED_PROVIDER = "<unnamed-provider>"; +function providerName(aProvider) { + return aProvider.name || UNNAMED_PROVIDER; +} + +// A reference to XPIProvider. This should only be used to access properties or +// methods that are independent of XPIProvider startup. +var gXPIProvider; + +/** + * Preference listener which listens for a change in the + * "extensions.logging.enabled" preference and changes the logging level of the + * parent 'addons' level logger accordingly. + */ +var PrefObserver = { + init() { + Services.prefs.addObserver(PREF_LOGGING_ENABLED, this); + Services.obs.addObserver(this, "xpcom-shutdown"); + this.observe(null, NS_PREFBRANCH_PREFCHANGE_TOPIC_ID, PREF_LOGGING_ENABLED); + }, + + observe(aSubject, aTopic, aData) { + if (aTopic == "xpcom-shutdown") { + Services.prefs.removeObserver(PREF_LOGGING_ENABLED, this); + Services.obs.removeObserver(this, "xpcom-shutdown"); + } else if (aTopic == NS_PREFBRANCH_PREFCHANGE_TOPIC_ID) { + let debugLogEnabled = Services.prefs.getBoolPref( + PREF_LOGGING_ENABLED, + false + ); + if (debugLogEnabled) { + parentLogger.level = Log.Level.Debug; + } else { + parentLogger.level = Log.Level.Warn; + } + } + }, +}; + +PrefObserver.init(); + +/** + * Calls a callback method consuming any thrown exception. Any parameters after + * the callback parameter will be passed to the callback. + * + * @param aCallback + * The callback method to call + */ +function safeCall(aCallback, ...aArgs) { + try { + aCallback.apply(null, aArgs); + } catch (e) { + logger.warn("Exception calling callback", e); + } +} + +/** + * Report an exception thrown by a provider API method. + */ +function reportProviderError(aProvider, aMethod, aError) { + let method = `provider ${providerName(aProvider)}.${aMethod}`; + AddonManagerPrivate.recordException("AMI", method, aError); + logger.error("Exception calling " + method, aError); +} + +/** + * Calls a method on a provider if it exists and consumes any thrown exception. + * Any parameters after the aDefault parameter are passed to the provider's method. + * + * @param aProvider + * The provider to call + * @param aMethod + * The method name to call + * @param aDefault + * A default return value if the provider does not implement the named + * method or throws an error. + * @return the return value from the provider, or aDefault if the provider does not + * implement method or throws an error + */ +function callProvider(aProvider, aMethod, aDefault, ...aArgs) { + if (!(aMethod in aProvider)) { + return aDefault; + } + + try { + return aProvider[aMethod].apply(aProvider, aArgs); + } catch (e) { + reportProviderError(aProvider, aMethod, e); + return aDefault; + } +} + +/** + * Calls a method on a provider if it exists and consumes any thrown exception. + * Parameters after aMethod are passed to aProvider.aMethod(). + * If the provider does not implement the method, or the method throws, calls + * the callback with 'undefined'. + * + * @param aProvider + * The provider to call + * @param aMethod + * The method name to call + */ +async function promiseCallProvider(aProvider, aMethod, ...aArgs) { + if (!(aMethod in aProvider)) { + return undefined; + } + try { + return aProvider[aMethod].apply(aProvider, aArgs); + } catch (e) { + reportProviderError(aProvider, aMethod, e); + return undefined; + } +} + +/** + * Gets the currently selected locale for display. + * @return the selected locale or "en-US" if none is selected + */ +function getLocale() { + return Services.locale.requestedLocale || "en-US"; +} + +const WEB_EXPOSED_ADDON_PROPERTIES = [ + "id", + "version", + "type", + "name", + "description", + "isActive", +]; + +function webAPIForAddon(addon) { + if (!addon) { + return null; + } + + // These web-exposed Addon properties (see AddonManager.webidl) + // just come directly from an Addon object. + let result = {}; + for (let prop of WEB_EXPOSED_ADDON_PROPERTIES) { + result[prop] = addon[prop]; + } + + // These properties are computed. + result.isEnabled = !addon.userDisabled; + result.canUninstall = Boolean( + addon.permissions & AddonManager.PERM_CAN_UNINSTALL + ); + + return result; +} + +/** + * Listens for a browser changing origin and cancels the installs that were + * started by it. + */ +function BrowserListener(aBrowser, aInstallingPrincipal, aInstall) { + this.browser = aBrowser; + this.messageManager = this.browser.messageManager; + this.principal = aInstallingPrincipal; + this.install = aInstall; + + aBrowser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); + Services.obs.addObserver(this, "message-manager-close", true); + + aInstall.addListener(this); + + this.registered = true; +} + +BrowserListener.prototype = { + browser: null, + install: null, + registered: false, + + unregister() { + if (!this.registered) { + return; + } + this.registered = false; + + Services.obs.removeObserver(this, "message-manager-close"); + // The browser may have already been detached + if (this.browser.removeProgressListener) { + this.browser.removeProgressListener(this); + } + + this.install.removeListener(this); + this.install = null; + }, + + cancelInstall() { + try { + this.install.cancel(); + } catch (e) { + // install may have already failed or been cancelled, ignore these + } + }, + + observe(subject, topic, data) { + if (subject != this.messageManager) { + return; + } + + // The browser's message manager has closed and so the browser is + // going away, cancel the install + this.cancelInstall(); + }, + + onLocationChange(webProgress, request, location) { + if ( + this.browser.contentPrincipal && + this.principal.subsumes(this.browser.contentPrincipal) + ) { + return; + } + + // The browser has navigated to a new origin so cancel the install + this.cancelInstall(); + }, + + onDownloadCancelled(install) { + this.unregister(); + }, + + onDownloadFailed(install) { + this.unregister(); + }, + + onInstallFailed(install) { + this.unregister(); + }, + + onInstallEnded(install) { + this.unregister(); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISupportsWeakReference", + "nsIWebProgressListener", + "nsIObserver", + ]), +}; + +/** + * This represents an author of an add-on (e.g. creator or developer) + * + * @param aName + * The name of the author + * @param aURL + * The URL of the author's profile page + */ +function AddonAuthor(aName, aURL) { + this.name = aName; + this.url = aURL; +} + +AddonAuthor.prototype = { + name: null, + url: null, + + // Returns the author's name, defaulting to the empty string + toString() { + return this.name || ""; + }, +}; + +/** + * This represents an screenshot for an add-on + * + * @param aURL + * The URL to the full version of the screenshot + * @param aWidth + * The width in pixels of the screenshot + * @param aHeight + * The height in pixels of the screenshot + * @param aThumbnailURL + * The URL to the thumbnail version of the screenshot + * @param aThumbnailWidth + * The width in pixels of the thumbnail version of the screenshot + * @param aThumbnailHeight + * The height in pixels of the thumbnail version of the screenshot + * @param aCaption + * The caption of the screenshot + */ +function AddonScreenshot( + aURL, + aWidth, + aHeight, + aThumbnailURL, + aThumbnailWidth, + aThumbnailHeight, + aCaption +) { + this.url = aURL; + if (aWidth) { + this.width = aWidth; + } + if (aHeight) { + this.height = aHeight; + } + if (aThumbnailURL) { + this.thumbnailURL = aThumbnailURL; + } + if (aThumbnailWidth) { + this.thumbnailWidth = aThumbnailWidth; + } + if (aThumbnailHeight) { + this.thumbnailHeight = aThumbnailHeight; + } + if (aCaption) { + this.caption = aCaption; + } +} + +AddonScreenshot.prototype = { + url: null, + width: null, + height: null, + thumbnailURL: null, + thumbnailWidth: null, + thumbnailHeight: null, + caption: null, + + // Returns the screenshot URL, defaulting to the empty string + toString() { + return this.url || ""; + }, +}; + +var gStarted = false; +var gStartedPromise = PromiseUtils.defer(); +var gStartupComplete = false; +var gCheckCompatibility = true; +var gStrictCompatibility = true; +var gCheckUpdateSecurityDefault = true; +var gCheckUpdateSecurity = gCheckUpdateSecurityDefault; +var gUpdateEnabled = true; +var gAutoUpdateDefault = true; +var gWebExtensionsMinPlatformVersion = ""; +var gFinalShutdownBarrier = null; +var gBeforeShutdownBarrier = null; +var gRepoShutdownState = ""; +var gShutdownInProgress = false; +var gBrowserUpdated = null; + +export var AMTelemetry; +export var AMRemoteSettings; + +/** + * This is the real manager, kept here rather than in AddonManager to keep its + * contents hidden from API users. + * @class + * @lends AddonManager + */ +var AddonManagerInternal = { + managerListeners: new Set(), + installListeners: new Set(), + addonListeners: new Set(), + pendingProviders: new Set(), + providers: new Set(), + providerShutdowns: new Map(), + typesByProvider: new Map(), + startupChanges: {}, + // Store telemetry details per addon provider + telemetryDetails: {}, + upgradeListeners: new Map(), + externalExtensionLoaders: new Map(), + + recordTimestamp(name, value) { + lazy.TelemetryTimestamps.add(name, value); + }, + + /** + * Start up a provider, and register its shutdown hook if it has one + * + * @param {string} aProvider - An add-on provider. + * @param {boolean} aAppChanged - Whether or not the app version has changed since last session. + * @param {string} aOldAppVersion - Previous application version, if changed. + * @param {string} aOldPlatformVersion - Previous platform version, if changed. + * + * @private + */ + _startProvider(aProvider, aAppChanged, aOldAppVersion, aOldPlatformVersion) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + logger.debug(`Starting provider: ${providerName(aProvider)}`); + callProvider( + aProvider, + "startup", + null, + aAppChanged, + aOldAppVersion, + aOldPlatformVersion + ); + if ("shutdown" in aProvider) { + let name = providerName(aProvider); + let AMProviderShutdown = () => { + // If the provider has been unregistered, it will have been removed from + // this.providers. If it hasn't been unregistered, then this is a normal + // shutdown - and we move it to this.pendingProviders in case we're + // running in a test that will start AddonManager again. + if (this.providers.has(aProvider)) { + this.providers.delete(aProvider); + this.pendingProviders.add(aProvider); + } + + return new Promise((resolve, reject) => { + logger.debug("Calling shutdown blocker for " + name); + resolve(aProvider.shutdown()); + }).catch(err => { + logger.warn("Failure during shutdown of " + name, err); + AddonManagerPrivate.recordException( + "AMI", + "Async shutdown of " + name, + err + ); + }); + }; + logger.debug("Registering shutdown blocker for " + name); + this.providerShutdowns.set(aProvider, AMProviderShutdown); + AddonManagerPrivate.finalShutdown.addBlocker(name, AMProviderShutdown); + } + + this.pendingProviders.delete(aProvider); + this.providers.add(aProvider); + logger.debug(`Provider finished startup: ${providerName(aProvider)}`); + }, + + _getProviderByName(aName) { + for (let provider of this.providers) { + if (providerName(provider) == aName) { + return provider; + } + } + return undefined; + }, + + /** + * Initializes the AddonManager, loading any known providers and initializing + * them. + */ + startup() { + try { + if (gStarted) { + return; + } + + this.recordTimestamp("AMI_startup_begin"); + + // Enable the addonsManager telemetry event category. + AMTelemetry.init(); + + // Enable the AMRemoteSettings client. + AMRemoteSettings.init(); + + // clear this for xpcshell test restarts + for (let provider in this.telemetryDetails) { + delete this.telemetryDetails[provider]; + } + + let appChanged = undefined; + + let oldAppVersion = null; + try { + oldAppVersion = Services.prefs.getCharPref(PREF_EM_LAST_APP_VERSION); + appChanged = Services.appinfo.version != oldAppVersion; + } catch (e) {} + + gBrowserUpdated = appChanged; + + let oldPlatformVersion = Services.prefs.getCharPref( + PREF_EM_LAST_PLATFORM_VERSION, + "" + ); + + if (appChanged !== false) { + logger.debug("Application has been upgraded"); + Services.prefs.setCharPref( + PREF_EM_LAST_APP_VERSION, + Services.appinfo.version + ); + Services.prefs.setCharPref( + PREF_EM_LAST_PLATFORM_VERSION, + Services.appinfo.platformVersion + ); + Services.prefs.setIntPref( + PREF_BLOCKLIST_PINGCOUNTVERSION, + appChanged === undefined ? 0 : -1 + ); + } + + if (!MOZ_COMPATIBILITY_NIGHTLY) { + PREF_EM_CHECK_COMPATIBILITY = + PREF_EM_CHECK_COMPATIBILITY_BASE + + "." + + Services.appinfo.version.replace(BRANCH_REGEXP, "$1"); + } + + gCheckCompatibility = Services.prefs.getBoolPref( + PREF_EM_CHECK_COMPATIBILITY, + gCheckCompatibility + ); + Services.prefs.addObserver(PREF_EM_CHECK_COMPATIBILITY, this); + + gStrictCompatibility = Services.prefs.getBoolPref( + PREF_EM_STRICT_COMPATIBILITY, + gStrictCompatibility + ); + Services.prefs.addObserver(PREF_EM_STRICT_COMPATIBILITY, this); + + let defaultBranch = Services.prefs.getDefaultBranch(""); + gCheckUpdateSecurityDefault = defaultBranch.getBoolPref( + PREF_EM_CHECK_UPDATE_SECURITY, + gCheckUpdateSecurityDefault + ); + + gCheckUpdateSecurity = Services.prefs.getBoolPref( + PREF_EM_CHECK_UPDATE_SECURITY, + gCheckUpdateSecurity + ); + Services.prefs.addObserver(PREF_EM_CHECK_UPDATE_SECURITY, this); + + gUpdateEnabled = Services.prefs.getBoolPref( + PREF_EM_UPDATE_ENABLED, + gUpdateEnabled + ); + Services.prefs.addObserver(PREF_EM_UPDATE_ENABLED, this); + + gAutoUpdateDefault = Services.prefs.getBoolPref( + PREF_EM_AUTOUPDATE_DEFAULT, + gAutoUpdateDefault + ); + Services.prefs.addObserver(PREF_EM_AUTOUPDATE_DEFAULT, this); + + gWebExtensionsMinPlatformVersion = Services.prefs.getCharPref( + PREF_MIN_WEBEXT_PLATFORM_VERSION, + gWebExtensionsMinPlatformVersion + ); + Services.prefs.addObserver(PREF_MIN_WEBEXT_PLATFORM_VERSION, this); + + // Watch for changes to PREF_REMOTESETTINGS_DISABLED. + Services.prefs.addObserver(PREF_REMOTESETTINGS_DISABLED, this); + + // Watch for language changes, refresh the addon cache when it changes. + Services.obs.addObserver(this, INTL_LOCALES_CHANGED); + + // Ensure all default providers have had a chance to register themselves + ({ XPIProvider: gXPIProvider } = ChromeUtils.import( + "resource://gre/modules/addons/XPIProvider.jsm" + )); + + // Load any providers registered in the category manager + for (let { entry, value: url } of Services.catMan.enumerateCategory( + CATEGORY_PROVIDER_MODULE + )) { + try { + ChromeUtils.importESModule(url); + logger.debug(`Loaded provider scope for ${url}`); + } catch (e) { + AddonManagerPrivate.recordException( + "AMI", + "provider " + url + " load failed", + e + ); + logger.error( + "Exception loading provider " + + entry + + ' from category "' + + url + + '"', + e + ); + } + } + + // Register our shutdown handler with the AsyncShutdown manager + gBeforeShutdownBarrier = new AsyncShutdown.Barrier( + "AddonManager: Waiting to start provider shutdown." + ); + gFinalShutdownBarrier = new AsyncShutdown.Barrier( + "AddonManager: Waiting for providers to shut down." + ); + AsyncShutdown.profileBeforeChange.addBlocker( + "AddonManager: shutting down.", + this.shutdownManager.bind(this), + { fetchState: this.shutdownState.bind(this) } + ); + + // Once we start calling providers we must allow all normal methods to work. + gStarted = true; + + for (let provider of this.pendingProviders) { + this._startProvider( + provider, + appChanged, + oldAppVersion, + oldPlatformVersion + ); + } + + // If this is a new profile just pretend that there were no changes + if (appChanged === undefined) { + for (let type in this.startupChanges) { + delete this.startupChanges[type]; + } + } + + gStartupComplete = true; + gStartedPromise.resolve(); + this.recordTimestamp("AMI_startup_end"); + } catch (e) { + logger.error("startup failed", e); + AddonManagerPrivate.recordException("AMI", "startup failed", e); + gStartedPromise.reject("startup failed"); + } + + // Disable the quarantined domains feature if the system add-on has been + // disabled in a previous version. + if ( + Services.prefs.getBoolPref( + "extensions.webextensions.addons-restricted-domains@mozilla.com.disabled", + false + ) + ) { + Services.prefs.setBoolPref( + "extensions.quarantinedDomains.enabled", + false + ); + logger.debug( + "Disabled quarantined domains because the system add-on was disabled" + ); + } + + logger.debug("Completed startup sequence"); + this.callManagerListeners("onStartup"); + }, + + /** + * Registers a new AddonProvider. + * + * @param {string} aProvider -The provider to register + * @param {string[]} [aTypes] - An optional array of add-on types + */ + registerProvider(aProvider, aTypes) { + if (!aProvider || typeof aProvider != "object") { + throw Components.Exception( + "aProvider must be specified", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (aTypes && !Array.isArray(aTypes)) { + throw Components.Exception( + "aTypes must be an array or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.pendingProviders.add(aProvider); + + if (aTypes) { + this.typesByProvider.set(aProvider, new Set(aTypes)); + } + + // If we're registering after startup call this provider's startup. + if (gStarted) { + this._startProvider(aProvider); + } + }, + + /** + * Unregisters an AddonProvider. + * + * @param aProvider + * The provider to unregister + * @return Whatever the provider's 'shutdown' method returns (if anything). + * For providers that have async shutdown methods returning Promises, + * the caller should wait for that Promise to resolve. + */ + unregisterProvider(aProvider) { + if (!aProvider || typeof aProvider != "object") { + throw Components.Exception( + "aProvider must be specified", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.providers.delete(aProvider); + // The test harness will unregister XPIProvider *after* shutdown, which is + // after the provider will have been moved from providers to + // pendingProviders. + this.pendingProviders.delete(aProvider); + + this.typesByProvider.delete(aProvider); + + // If we're unregistering after startup but before shutting down, + // remove the blocker for this provider's shutdown and call it. + // If we're already shutting down, just let gFinalShutdownBarrier + // call it to avoid races. + if (gStarted && !gShutdownInProgress) { + logger.debug( + "Unregistering shutdown blocker for " + providerName(aProvider) + ); + let shutter = this.providerShutdowns.get(aProvider); + if (shutter) { + this.providerShutdowns.delete(aProvider); + gFinalShutdownBarrier.client.removeBlocker(shutter); + return shutter(); + } + } + return undefined; + }, + + /** + * Mark a provider as safe to access via AddonManager APIs, before its + * startup has completed. + * + * Normally a provider isn't marked as safe until after its (synchronous) + * startup() method has returned. Until a provider has been marked safe, + * it won't be used by any of the AddonManager APIs. markProviderSafe() + * allows a provider to mark itself as safe during its startup; this can be + * useful if the provider wants to perform tasks that block startup, which + * happen after its required initialization tasks and therefore when the + * provider is in a safe state. + * + * @param aProvider Provider object to mark safe + */ + markProviderSafe(aProvider) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!aProvider || typeof aProvider != "object") { + throw Components.Exception( + "aProvider must be specified", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (!this.pendingProviders.has(aProvider)) { + return; + } + + this.pendingProviders.delete(aProvider); + this.providers.add(aProvider); + }, + + /** + * Calls a method on all registered providers if it exists and consumes any + * thrown exception. Return values are ignored. Any parameters after the + * method parameter are passed to the provider's method. + * WARNING: Do not use for asynchronous calls; callProviders() does not + * invoke callbacks if provider methods throw synchronous exceptions. + * + * @param aMethod + * The method name to call + */ + callProviders(aMethod, ...aArgs) { + if (!aMethod || typeof aMethod != "string") { + throw Components.Exception( + "aMethod must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let providers = [...this.providers]; + for (let provider of providers) { + try { + if (aMethod in provider) { + provider[aMethod].apply(provider, aArgs); + } + } catch (e) { + reportProviderError(provider, aMethod, e); + } + } + }, + + /** + * Report the current state of asynchronous shutdown + */ + shutdownState() { + let state = []; + for (let barrier of [gBeforeShutdownBarrier, gFinalShutdownBarrier]) { + if (barrier) { + state.push({ name: barrier.client.name, state: barrier.state }); + } + } + state.push({ + name: "AddonRepository: async shutdown", + state: gRepoShutdownState, + }); + return state; + }, + + /** + * Shuts down the addon manager and all registered providers, this must clean + * up everything in order for automated tests to fake restarts. + * @return Promise{null} that resolves when all providers and dependent modules + * have finished shutting down + */ + async shutdownManager() { + logger.debug("before shutdown"); + try { + await gBeforeShutdownBarrier.wait(); + } catch (e) { + Cu.reportError(e); + } + + logger.debug("shutdown"); + this.callManagerListeners("onShutdown"); + + if (!gStartupComplete) { + gStartedPromise.reject("shutting down"); + } + + gRepoShutdownState = "pending"; + gShutdownInProgress = true; + + // Clean up listeners + Services.prefs.removeObserver(PREF_EM_CHECK_COMPATIBILITY, this); + Services.prefs.removeObserver(PREF_EM_STRICT_COMPATIBILITY, this); + Services.prefs.removeObserver(PREF_EM_CHECK_UPDATE_SECURITY, this); + Services.prefs.removeObserver(PREF_EM_UPDATE_ENABLED, this); + Services.prefs.removeObserver(PREF_EM_AUTOUPDATE_DEFAULT, this); + Services.prefs.removeObserver(PREF_REMOTESETTINGS_DISABLED, this); + + Services.obs.removeObserver(this, INTL_LOCALES_CHANGED); + + AMRemoteSettings.shutdown(); + + let savedError = null; + // Only shut down providers if they've been started. + if (gStarted) { + try { + await gFinalShutdownBarrier.wait(); + } catch (err) { + savedError = err; + logger.error("Failure during wait for shutdown barrier", err); + AddonManagerPrivate.recordException( + "AMI", + "Async shutdown of AddonManager providers", + err + ); + } + } + gXPIProvider = null; + + // Shut down AddonRepository after providers (if any). + try { + gRepoShutdownState = "in progress"; + await lazy.AddonRepository.shutdown(); + gRepoShutdownState = "done"; + } catch (err) { + savedError = err; + logger.error("Failure during AddonRepository shutdown", err); + AddonManagerPrivate.recordException( + "AMI", + "Async shutdown of AddonRepository", + err + ); + } + + logger.debug("Async provider shutdown done"); + this.managerListeners.clear(); + this.installListeners.clear(); + this.addonListeners.clear(); + this.providerShutdowns.clear(); + for (let type in this.startupChanges) { + delete this.startupChanges[type]; + } + gStarted = false; + gStartedPromise = PromiseUtils.defer(); + gStartupComplete = false; + gFinalShutdownBarrier = null; + gBeforeShutdownBarrier = null; + gShutdownInProgress = false; + if (savedError) { + throw savedError; + } + }, + + /** + * Notified when a preference we're interested in has changed. + */ + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case INTL_LOCALES_CHANGED: { + // Asynchronously fetch and update the addons cache. + lazy.AddonRepository.backgroundUpdateCheck(); + return; + } + } + + switch (aData) { + case PREF_EM_CHECK_COMPATIBILITY: { + let oldValue = gCheckCompatibility; + gCheckCompatibility = Services.prefs.getBoolPref( + PREF_EM_CHECK_COMPATIBILITY, + true + ); + + this.callManagerListeners("onCompatibilityModeChanged"); + + if (gCheckCompatibility != oldValue) { + this.updateAddonAppDisabledStates(); + } + + break; + } + case PREF_EM_STRICT_COMPATIBILITY: { + let oldValue = gStrictCompatibility; + gStrictCompatibility = Services.prefs.getBoolPref( + PREF_EM_STRICT_COMPATIBILITY, + true + ); + + this.callManagerListeners("onCompatibilityModeChanged"); + + if (gStrictCompatibility != oldValue) { + this.updateAddonAppDisabledStates(); + } + + break; + } + case PREF_EM_CHECK_UPDATE_SECURITY: { + let oldValue = gCheckUpdateSecurity; + gCheckUpdateSecurity = Services.prefs.getBoolPref( + PREF_EM_CHECK_UPDATE_SECURITY, + true + ); + + this.callManagerListeners("onCheckUpdateSecurityChanged"); + + if (gCheckUpdateSecurity != oldValue) { + this.updateAddonAppDisabledStates(); + } + + break; + } + case PREF_EM_UPDATE_ENABLED: { + gUpdateEnabled = Services.prefs.getBoolPref( + PREF_EM_UPDATE_ENABLED, + true + ); + + this.callManagerListeners("onUpdateModeChanged"); + break; + } + case PREF_EM_AUTOUPDATE_DEFAULT: { + gAutoUpdateDefault = Services.prefs.getBoolPref( + PREF_EM_AUTOUPDATE_DEFAULT, + true + ); + + this.callManagerListeners("onUpdateModeChanged"); + break; + } + case PREF_MIN_WEBEXT_PLATFORM_VERSION: { + gWebExtensionsMinPlatformVersion = Services.prefs.getCharPref( + PREF_MIN_WEBEXT_PLATFORM_VERSION + ); + break; + } + case PREF_REMOTESETTINGS_DISABLED: { + if (Services.prefs.getBoolPref(PREF_REMOTESETTINGS_DISABLED, false)) { + AMRemoteSettings.shutdown(); + } else { + AMRemoteSettings.init(); + } + break; + } + } + }, + + /** + * Replaces %...% strings in an addon url (update and updateInfo) with + * appropriate values. + * + * @param aAddon + * The Addon representing the add-on + * @param aUri + * The string representation of the URI to escape + * @param aAppVersion + * The optional application version to use for %APP_VERSION% + * @return The appropriately escaped URI. + */ + escapeAddonURI(aAddon, aUri, aAppVersion) { + if (!aAddon || typeof aAddon != "object") { + throw Components.Exception( + "aAddon must be an Addon object", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (!aUri || typeof aUri != "string") { + throw Components.Exception( + "aUri must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (aAppVersion && typeof aAppVersion != "string") { + throw Components.Exception( + "aAppVersion must be a string or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + var addonStatus = + aAddon.userDisabled || aAddon.softDisabled + ? "userDisabled" + : "userEnabled"; + + if (!aAddon.isCompatible) { + addonStatus += ",incompatible"; + } + + let { blocklistState } = aAddon; + if (blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED) { + addonStatus += ",blocklisted"; + } + if (blocklistState == Ci.nsIBlocklistService.STATE_SOFTBLOCKED) { + addonStatus += ",softblocked"; + } + + let params = new Map( + Object.entries({ + ITEM_ID: aAddon.id, + ITEM_VERSION: aAddon.version, + ITEM_STATUS: addonStatus, + APP_ID: Services.appinfo.ID, + APP_VERSION: aAppVersion ? aAppVersion : Services.appinfo.version, + REQ_VERSION: UPDATE_REQUEST_VERSION, + APP_OS: Services.appinfo.OS, + APP_ABI: Services.appinfo.XPCOMABI, + APP_LOCALE: getLocale(), + CURRENT_APP_VERSION: Services.appinfo.version, + }) + ); + + let uri = aUri.replace(/%([A-Z_]+)%/g, (m0, m1) => params.get(m1) || m0); + + // escape() does not properly encode + symbols in any embedded FVF strings. + return uri.replace(/\+/g, "%2B"); + }, + + _updatePromptHandler(info) { + let oldPerms = info.existingAddon.userPermissions; + if (!oldPerms) { + // Updating from a legacy add-on, just let it proceed + return Promise.resolve(); + } + + let newPerms = info.addon.userPermissions; + + let difference = lazy.Extension.comparePermissions(oldPerms, newPerms); + + // If there are no new permissions, just go ahead with the update + if (!difference.origins.length && !difference.permissions.length) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + let subject = { + wrappedJSObject: { + addon: info.addon, + permissions: difference, + resolve, + reject, + // Reference to the related AddonInstall object (used in AMTelemetry to + // link the recorded event to the other events from the same install flow). + install: info.install, + }, + }; + Services.obs.notifyObservers(subject, "webextension-update-permissions"); + }); + }, + + // Returns true if System Addons should be updated + systemUpdateEnabled() { + if (!Services.prefs.getBoolPref(PREF_SYS_ADDON_UPDATE_ENABLED)) { + return false; + } + if (Services.policies && !Services.policies.isAllowed("SysAddonUpdate")) { + return false; + } + return true; + }, + + /** + * Performs a background update check by starting an update for all add-ons + * that can be updated. + * @return Promise{null} Resolves when the background update check is complete + * (the resulting addon installations may still be in progress). + */ + backgroundUpdateCheck() { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + let buPromise = (async () => { + logger.debug("Background update check beginning"); + + Services.obs.notifyObservers(null, "addons-background-update-start"); + + if (this.updateEnabled) { + // Keep track of all the async add-on updates happening in parallel + let updates = []; + + let allAddons = await this.getAllAddons(); + + // Repopulate repository cache first, to ensure compatibility overrides + // are up to date before checking for addon updates. + await lazy.AddonRepository.backgroundUpdateCheck(); + + for (let addon of allAddons) { + // Check all add-ons for updates so that any compatibility updates will + // be applied + + if (!(addon.permissions & AddonManager.PERM_CAN_UPGRADE)) { + continue; + } + + updates.push( + new Promise((resolve, reject) => { + addon.findUpdates( + { + onUpdateAvailable(aAddon, aInstall) { + // Start installing updates when the add-on can be updated and + // background updates should be applied. + logger.debug("Found update for add-on ${id}", aAddon); + if (AddonManager.shouldAutoUpdate(aAddon)) { + // XXX we really should resolve when this install is done, + // not when update-available check completes, no? + logger.debug(`Starting upgrade install of ${aAddon.id}`); + aInstall.promptHandler = (...args) => + AddonManagerInternal._updatePromptHandler(...args); + aInstall.install(); + } + }, + + onUpdateFinished: aAddon => { + logger.debug("onUpdateFinished for ${id}", aAddon); + resolve(); + }, + }, + AddonManager.UPDATE_WHEN_PERIODIC_UPDATE + ); + }) + ); + } + Services.obs.notifyObservers( + null, + "addons-background-updates-found", + updates.length + ); + await Promise.all(updates); + } + + if (AddonManagerInternal.systemUpdateEnabled()) { + try { + await AddonManagerInternal._getProviderByName( + "XPIProvider" + ).updateSystemAddons(); + } catch (e) { + logger.warn("Failed to update system addons", e); + } + } + + logger.debug("Background update check complete"); + Services.obs.notifyObservers(null, "addons-background-update-complete"); + })(); + // Fork the promise chain so we can log the error and let our caller see it too. + buPromise.catch(e => logger.warn("Error in background update", e)); + return buPromise; + }, + + /** + * Adds a add-on to the list of detected changes for this startup. If + * addStartupChange is called multiple times for the same add-on in the same + * startup then only the most recent change will be remembered. + * + * @param aType + * The type of change as a string. Providers can define their own + * types of changes or use the existing defined STARTUP_CHANGE_* + * constants + * @param aID + * The ID of the add-on + */ + addStartupChange(aType, aID) { + if (!aType || typeof aType != "string") { + throw Components.Exception( + "aType must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (!aID || typeof aID != "string") { + throw Components.Exception( + "aID must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (gStartupComplete) { + return; + } + logger.debug("Registering startup change '" + aType + "' for " + aID); + + // Ensure that an ID is only listed in one type of change + for (let type in this.startupChanges) { + this.removeStartupChange(type, aID); + } + + if (!(aType in this.startupChanges)) { + this.startupChanges[aType] = []; + } + this.startupChanges[aType].push(aID); + }, + + /** + * Removes a startup change for an add-on. + * + * @param aType + * The type of change + * @param aID + * The ID of the add-on + */ + removeStartupChange(aType, aID) { + if (!aType || typeof aType != "string") { + throw Components.Exception( + "aType must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (!aID || typeof aID != "string") { + throw Components.Exception( + "aID must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (gStartupComplete) { + return; + } + + if (!(aType in this.startupChanges)) { + return; + } + + this.startupChanges[aType] = this.startupChanges[aType].filter( + aItem => aItem != aID + ); + }, + + /** + * Calls all registered AddonManagerListeners with an event. Any parameters + * after the method parameter are passed to the listener. + * + * @param aMethod + * The method on the listeners to call + */ + callManagerListeners(aMethod, ...aArgs) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!aMethod || typeof aMethod != "string") { + throw Components.Exception( + "aMethod must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let managerListeners = new Set(this.managerListeners); + for (let listener of managerListeners) { + try { + if (aMethod in listener) { + listener[aMethod].apply(listener, aArgs); + } + } catch (e) { + logger.warn( + "AddonManagerListener threw exception when calling " + aMethod, + e + ); + } + } + }, + + /** + * Calls all registered InstallListeners with an event. Any parameters after + * the extraListeners parameter are passed to the listener. + * + * @param aMethod + * The method on the listeners to call + * @param aExtraListeners + * An optional array of extra InstallListeners to also call + * @return false if any of the listeners returned false, true otherwise + */ + callInstallListeners(aMethod, aExtraListeners, ...aArgs) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!aMethod || typeof aMethod != "string") { + throw Components.Exception( + "aMethod must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (aExtraListeners && !Array.isArray(aExtraListeners)) { + throw Components.Exception( + "aExtraListeners must be an array or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let result = true; + let listeners; + if (aExtraListeners) { + listeners = new Set( + aExtraListeners.concat(Array.from(this.installListeners)) + ); + } else { + listeners = new Set(this.installListeners); + } + + for (let listener of listeners) { + try { + if (aMethod in listener) { + if (listener[aMethod].apply(listener, aArgs) === false) { + result = false; + } + } + } catch (e) { + logger.warn( + "InstallListener threw exception when calling " + aMethod, + e + ); + } + } + return result; + }, + + /** + * Calls all registered AddonListeners with an event. Any parameters after + * the method parameter are passed to the listener. + * + * @param aMethod + * The method on the listeners to call + */ + callAddonListeners(aMethod, ...aArgs) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!aMethod || typeof aMethod != "string") { + throw Components.Exception( + "aMethod must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let addonListeners = new Set(this.addonListeners); + for (let listener of addonListeners) { + try { + if (aMethod in listener) { + listener[aMethod].apply(listener, aArgs); + } + } catch (e) { + logger.warn("AddonListener threw exception when calling " + aMethod, e); + } + } + }, + + /** + * Notifies all providers that an add-on has been enabled when that type of + * add-on only supports a single add-on being enabled at a time. This allows + * the providers to disable theirs if necessary. + * + * @param aID + * The ID of the enabled add-on + * @param aType + * The type of the enabled add-on + * @param aPendingRestart + * A boolean indicating if the change will only take place the next + * time the application is restarted + */ + async notifyAddonChanged(aID, aType, aPendingRestart) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (aID && typeof aID != "string") { + throw Components.Exception( + "aID must be a string or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (!aType || typeof aType != "string") { + throw Components.Exception( + "aType must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // Temporary hack until bug 520124 lands. + // We can get here during synchronous startup, at which point it's + // considered unsafe (and therefore disallowed by AddonManager.jsm) to + // access providers that haven't been initialized yet. Since this is when + // XPIProvider is starting up, XPIProvider can't access itself via APIs + // going through AddonManager.jsm. Thankfully, this is the only use + // of this API, and we know it's safe to use this API with both + // providers; so we have this hack to allow bypassing the normal + // safetey guard. + // The notifyAddonChanged/addonChanged API will be unneeded and therefore + // removed by bug 520124, so this is a temporary quick'n'dirty hack. + let providers = [...this.providers, ...this.pendingProviders]; + for (let provider of providers) { + let result = callProvider( + provider, + "addonChanged", + null, + aID, + aType, + aPendingRestart + ); + if (result) { + await result; + } + } + }, + + /** + * Notifies all providers they need to update the appDisabled property for + * their add-ons in response to an application change such as a blocklist + * update. + */ + updateAddonAppDisabledStates() { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + this.callProviders("updateAddonAppDisabledStates"); + }, + + /** + * Notifies all providers that the repository has updated its data for + * installed add-ons. + */ + updateAddonRepositoryData() { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + return (async () => { + for (let provider of this.providers) { + await promiseCallProvider(provider, "updateAddonRepositoryData"); + } + + // only tests should care about this + Services.obs.notifyObservers(null, "TEST:addon-repository-data-updated"); + })(); + }, + + /** + * Asynchronously gets an AddonInstall for a URL. + * + * @param aUrl + * The string represenation of the URL where the add-on is located + * @param {Object} [aOptions = {}] + * Additional options for this install + * @param {string} [aOptions.hash] + * An optional hash of the add-on + * @param {string} [aOptions.name] + * An optional placeholder name while the add-on is being downloaded + * @param {string|Object} [aOptions.icons] + * Optional placeholder icons while the add-on is being downloaded + * @param {string} [aOptions.version] + * An optional placeholder version while the add-on is being downloaded + * @param {XULElement} [aOptions.browser] + * An optional <browser> element for download permissions prompts. + * @param {nsIPrincipal} [aOptions.triggeringPrincipal] + * The principal which is attempting to install the add-on. + * @param {Object} [aOptions.telemetryInfo] + * An optional object which provides details about the installation source + * included in the addon manager telemetry events. + * @throws if aUrl is not specified or if an optional argument of + * an improper type is passed. + */ + async getInstallForURL(aUrl, aOptions = {}) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!aUrl || typeof aUrl != "string") { + throw Components.Exception( + "aURL must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (aOptions.hash && typeof aOptions.hash != "string") { + throw Components.Exception( + "hash must be a string or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (aOptions.name && typeof aOptions.name != "string") { + throw Components.Exception( + "name must be a string or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (aOptions.icons) { + if (typeof aOptions.icons == "string") { + aOptions.icons = { 32: aOptions.icons }; + } else if (typeof aOptions.icons != "object") { + throw Components.Exception( + "icons must be a string, an object or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + } else { + aOptions.icons = {}; + } + + if (aOptions.version && typeof aOptions.version != "string") { + throw Components.Exception( + "version must be a string or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (aOptions.browser && !Element.isInstance(aOptions.browser)) { + throw Components.Exception( + "aOptions.browser must be an Element or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + for (let provider of this.providers) { + let install = await promiseCallProvider( + provider, + "getInstallForURL", + aUrl, + aOptions + ); + if (install) { + return install; + } + } + + return null; + }, + + /** + * Asynchronously gets an AddonInstall for an nsIFile. + * + * @param aFile + * The nsIFile where the add-on is located + * @param aMimetype + * An optional mimetype hint for the add-on + * @param aTelemetryInfo + * An optional object which provides details about the installation source + * included in the addon manager telemetry events. + * @param aUseSystemLocation + * If true the addon is installed into the system profile location. + * @throws if the aFile or aCallback arguments are not specified + */ + getInstallForFile(aFile, aMimetype, aTelemetryInfo, aUseSystemLocation) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!(aFile instanceof Ci.nsIFile)) { + throw Components.Exception( + "aFile must be a nsIFile", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (aMimetype && typeof aMimetype != "string") { + throw Components.Exception( + "aMimetype must be a string or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + return (async () => { + for (let provider of this.providers) { + let install = await promiseCallProvider( + provider, + "getInstallForFile", + aFile, + aTelemetryInfo, + aUseSystemLocation + ); + + if (install) { + return install; + } + } + + return null; + })(); + }, + + /** + * Get a SitePermsAddonInstall instance. + * + * @param {Element} aBrowser: The optional browser element that started the install + * @param {nsIPrincipal} aInstallingPrincipal + * @param {String} aSitePerm + * @returns {Promise<SitePermsAddonInstall|null>} The promise will resolve with null if there + * are no provider with a getSitePermsAddonInstallForWebpage method. In practice, + * this should only be the case when SitePermsAddonProvider is not enabled, + * i.e. when dom.sitepermsaddon-provider.enabled is false. + * @throws {Components.Exception} Will throw an error if: + * - the AddonManager is not initialized + * - `aInstallingPrincipal` is not a nsIPrincipal + * - `aInstallingPrincipal` scheme is not https + * - `aInstallingPrincipal` is a public etld + * - `aInstallingPrincipal` is a plain ip address + * - `aInstallingPrincipal` is in the blocklist + * - `aSitePerm` is not a gated permission + * - `aBrowser` is not null and not an element + */ + async getSitePermsAddonInstallForWebpage( + aBrowser, + aInstallingPrincipal, + aSitePerm + ) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if ( + !aInstallingPrincipal || + !(aInstallingPrincipal instanceof Ci.nsIPrincipal) + ) { + throw Components.Exception( + "aInstallingPrincipal must be a nsIPrincipal", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (aBrowser && !Element.isInstance(aBrowser)) { + throw Components.Exception( + "aBrowser must be an Element, or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (lazy.isPrincipalInSitePermissionsBlocklist(aInstallingPrincipal)) { + throw Components.Exception( + `SitePermsAddons can't be installed`, + Cr.NS_ERROR_INVALID_ARG + ); + } + + // Block install from null principal. + // /!\ We need to do this check before checking if this is a remote origin iframe, + // otherwise isThirdPartyPrincipal might throw. + if (aInstallingPrincipal.isNullPrincipal) { + throw Components.Exception( + `SitePermsAddons can't be installed from sandboxed subframes`, + Cr.NS_ERROR_INVALID_ARG + ); + } + + // Block install from remote origin iframe + if ( + aBrowser && + aBrowser.contentPrincipal.isThirdPartyPrincipal(aInstallingPrincipal) + ) { + throw Components.Exception( + `SitePermsAddons can't be installed from cross origin subframes`, + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (aInstallingPrincipal.isIpAddress) { + throw Components.Exception( + `SitePermsAddons install disallowed when the host is an IP address`, + Cr.NS_ERROR_INVALID_ARG + ); + } + + // Gated APIs should probably not be available on non-secure origins, + // but let's double check here. + if (aInstallingPrincipal.scheme !== "https") { + throw Components.Exception( + `SitePermsAddons can only be installed from secure origins`, + Cr.NS_ERROR_INVALID_ARG + ); + } + + // Install origin cannot be on a known etld (e.g. github.io). + if (lazy.isKnownPublicSuffix(aInstallingPrincipal.siteOriginNoSuffix)) { + throw Components.Exception( + `SitePermsAddon can't be installed from public eTLDs ${aInstallingPrincipal.siteOriginNoSuffix}`, + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (!lazy.isGatedPermissionType(aSitePerm)) { + throw Components.Exception( + `"${aSitePerm}" is not a gated permission`, + Cr.NS_ERROR_INVALID_ARG + ); + } + + for (let provider of this.providers) { + let install = await promiseCallProvider( + provider, + "getSitePermsAddonInstallForWebpage", + aInstallingPrincipal, + aSitePerm + ); + if (install) { + return install; + } + } + + return null; + }, + + /** + * Uninstall an addon from the system profile location. + * + * @param {string} aID + * The ID of the addon to remove. + * @returns A promise that resolves when the addon is uninstalled. + */ + uninstallSystemProfileAddon(aID) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + return AddonManagerInternal._getProviderByName( + "XPIProvider" + ).uninstallSystemProfileAddon(aID); + }, + + /** + * Asynchronously gets all current AddonInstalls optionally limiting to a list + * of types. + * + * @param aTypes + * An optional array of types to retrieve. Each type is a string name + * @throws If the aCallback argument is not specified + */ + getInstallsByTypes(aTypes) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (aTypes && !Array.isArray(aTypes)) { + throw Components.Exception( + "aTypes must be an array or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + return (async () => { + let installs = []; + + for (let provider of this.providers) { + let providerInstalls = await promiseCallProvider( + provider, + "getInstallsByTypes", + aTypes + ); + + if (providerInstalls) { + installs.push(...providerInstalls); + } + } + + return installs; + })(); + }, + + /** + * Asynchronously gets all current AddonInstalls. + */ + getAllInstalls() { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + return this.getInstallsByTypes(null); + }, + + /** + * Checks whether installation is enabled for a particular mimetype. + * + * @param aMimetype + * The mimetype to check + * @return true if installation is enabled for the mimetype + */ + isInstallEnabled(aMimetype) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!aMimetype || typeof aMimetype != "string") { + throw Components.Exception( + "aMimetype must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let providers = [...this.providers]; + for (let provider of providers) { + if ( + callProvider(provider, "supportsMimetype", false, aMimetype) && + callProvider(provider, "isInstallEnabled") + ) { + return true; + } + } + return false; + }, + + /** + * Checks whether a particular source is allowed to install add-ons of a + * given mimetype. + * + * @param aMimetype + * The mimetype of the add-on + * @param aInstallingPrincipal + * The nsIPrincipal that initiated the install + * @return true if the source is allowed to install this mimetype + */ + isInstallAllowed(aMimetype, aInstallingPrincipal) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!aMimetype || typeof aMimetype != "string") { + throw Components.Exception( + "aMimetype must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if ( + !aInstallingPrincipal || + !(aInstallingPrincipal instanceof Ci.nsIPrincipal) + ) { + throw Components.Exception( + "aInstallingPrincipal must be a nsIPrincipal", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if ( + this.isInstallAllowedByPolicy( + aInstallingPrincipal, + null, + true /* explicit */ + ) + ) { + return true; + } + + let providers = [...this.providers]; + for (let provider of providers) { + if ( + callProvider(provider, "supportsMimetype", false, aMimetype) && + callProvider(provider, "isInstallAllowed", null, aInstallingPrincipal) + ) { + return true; + } + } + return false; + }, + + /** + * Checks whether a particular source is allowed to install add-ons based + * on policy. + * + * @param aInstallingPrincipal + * The nsIPrincipal that initiated the install + * @param aInstall + * The AddonInstall to be installed + * @param explicit + * If this is set, we only return true if the source is explicitly + * blocked via policy. + * + * @return boolean + * By default, returns true if the source is blocked by policy + * or there is no policy. + * If explicit is set, only returns true of the source is + * blocked by policy, false otherwise. This is needed for + * handling inverse cases. + */ + isInstallAllowedByPolicy(aInstallingPrincipal, aInstall, explicit) { + if (Services.policies) { + let extensionSettings = Services.policies.getExtensionSettings("*"); + if (extensionSettings && extensionSettings.install_sources) { + if ( + (!aInstall || + Services.policies.allowedInstallSource(aInstall.sourceURI)) && + (!aInstallingPrincipal || + !aInstallingPrincipal.URI || + Services.policies.allowedInstallSource(aInstallingPrincipal.URI)) + ) { + return true; + } + return false; + } + } + return !explicit; + }, + + installNotifyObservers( + aTopic, + aBrowser, + aUri, + aInstall, + aInstallFn, + aCancelFn + ) { + let info = { + wrappedJSObject: { + browser: aBrowser, + originatingURI: aUri, + installs: [aInstall], + install: aInstallFn, + cancel: aCancelFn, + }, + }; + Services.obs.notifyObservers(info, aTopic); + }, + + startInstall(browser, url, install) { + this.installNotifyObservers("addon-install-started", browser, url, install); + + // Local installs may already be in a failed state in which case + // we won't get any further events, detect those cases now. + if ( + install.state == AddonManager.STATE_DOWNLOADED && + install.addon.appDisabled + ) { + install.cancel(); + this.installNotifyObservers( + "addon-install-failed", + browser, + url, + install + ); + return; + } + + let self = this; + let listener = { + onDownloadCancelled() { + install.removeListener(listener); + }, + + onDownloadFailed() { + install.removeListener(listener); + self.installNotifyObservers( + "addon-install-failed", + browser, + url, + install + ); + }, + + onDownloadEnded() { + if (install.addon.appDisabled) { + // App disabled items are not compatible and so fail to install. + install.removeListener(listener); + install.cancel(); + self.installNotifyObservers( + "addon-install-failed", + browser, + url, + install + ); + } + }, + + onInstallCancelled() { + install.removeListener(listener); + }, + + onInstallFailed() { + install.removeListener(listener); + self.installNotifyObservers( + "addon-install-failed", + browser, + url, + install + ); + }, + + onInstallEnded() { + install.removeListener(listener); + + // If installing a theme that is disabled and can be enabled + // then enable it + if ( + install.addon.type == "theme" && + !!install.addon.userDisabled && + !install.addon.appDisabled + ) { + install.addon.enable(); + } + + let subject = { + wrappedJSObject: { target: browser, addon: install.addon }, + }; + Services.obs.notifyObservers(subject, "webextension-install-notify"); + }, + }; + + install.addListener(listener); + + // Start downloading if it hasn't already begun + install.install(); + }, + + /** + * Starts installation of a SitePermsAddonInstall notifying the registered + * web install listener of a blocked or started install. + * + * @param aBrowser + * The optional browser element that started the install + * @param aInstallingPrincipal + * The nsIPrincipal that initiated the install + * @param aPermission + * The permission to install + * @returns {Promise} A promise that will resolve when the user installs the addon. + * The promise will reject if the user blocked the install, or if the addon + * can't be installed (e.g. the principal isn't supported). + * @throws {Components.Exception} Will throw an error if the AddonManager is not initialized + * or if `aInstallingPrincipal` is not a nsIPrincipal. + */ + async installSitePermsAddonFromWebpage( + aBrowser, + aInstallingPrincipal, + aPermission + ) { + const synthAddonInstall = + await AddonManagerInternal.getSitePermsAddonInstallForWebpage( + aBrowser, + aInstallingPrincipal, + aPermission + ); + const promiseInstall = new Promise((resolve, reject) => { + const installListener = { + onInstallFailed() { + synthAddonInstall.removeListener(installListener); + reject(new Error("Install Failed")); + }, + + onInstallCancelled() { + synthAddonInstall.removeListener(installListener); + reject(new Error("Install Cancelled")); + }, + + onInstallEnded() { + synthAddonInstall.removeListener(installListener); + resolve(); + }, + }; + synthAddonInstall.addListener(installListener); + }); + + let startInstall = () => { + AddonManagerInternal.setupPromptHandler( + aBrowser, + aInstallingPrincipal.URI, + synthAddonInstall, + true, + "SitePermissionAddonPrompt" + ); + + AddonManagerInternal.startInstall( + aBrowser, + aInstallingPrincipal.URI, + synthAddonInstall + ); + }; + + startInstall(); + + return promiseInstall; + }, + + /** + * Starts installation of an AddonInstall notifying the registered + * web install listener of a blocked or started install. + * + * @param aMimetype + * The mimetype of the add-on being installed + * @param aBrowser + * The optional browser element that started the install + * @param aInstallingPrincipal + * The nsIPrincipal that initiated the install + * @param aInstall + * The AddonInstall to be installed + * @param [aDetails] + * Additional optional details + * @param [aDetails.hasCrossOriginAncestor] + * Boolean value set to true if any of cross-origin ancestors of the triggering frame + * (if set to true the installation will be denied). + */ + installAddonFromWebpage( + aMimetype, + aBrowser, + aInstallingPrincipal, + aInstall, + aDetails + ) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!aMimetype || typeof aMimetype != "string") { + throw Components.Exception( + "aMimetype must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (aBrowser && !Element.isInstance(aBrowser)) { + throw Components.Exception( + "aSource must be an Element, or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if ( + !aInstallingPrincipal || + !(aInstallingPrincipal instanceof Ci.nsIPrincipal) + ) { + throw Components.Exception( + "aInstallingPrincipal must be a nsIPrincipal", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // When a chrome in-content UI has loaded a <browser> inside to host a + // website we want to do our security checks on the inner-browser but + // notify front-end that install events came from the outer-browser (the + // main tab's browser). Check this by seeing if the browser we've been + // passed is in a content type docshell and if so get the outer-browser. + let topBrowser = aBrowser; + // GeckoView does not pass a browser. + if (aBrowser) { + let docShell = aBrowser.ownerGlobal.docShell; + if (docShell.itemType == Ci.nsIDocShellTreeItem.typeContent) { + topBrowser = docShell.chromeEventHandler; + } + } + + try { + // Use fullscreenElement to check for DOM fullscreen, while still allowing + // macOS fullscreen, which still has a browser chrome. + if (topBrowser && topBrowser.ownerDocument.fullscreenElement) { + // Addon installation and the resulting notifications should be + // blocked in DOM fullscreen for security and usability reasons. + // Installation prompts in fullscreen can trick the user into + // installing unwanted addons. + // In fullscreen the notification box does not have a clear + // visual association with its parent anymore. + aInstall.cancel(); + + this.installNotifyObservers( + "addon-install-fullscreen-blocked", + topBrowser, + aInstallingPrincipal.URI, + aInstall + ); + return; + } else if (!this.isInstallEnabled(aMimetype)) { + aInstall.cancel(); + + this.installNotifyObservers( + "addon-install-disabled", + topBrowser, + aInstallingPrincipal.URI, + aInstall + ); + return; + } else if ( + !this.isInstallAllowedByPolicy( + aInstallingPrincipal, + aInstall, + false /* explicit */ + ) + ) { + aInstall.cancel(); + + this.installNotifyObservers( + "addon-install-policy-blocked", + topBrowser, + aInstallingPrincipal.URI, + aInstall + ); + return; + } else if ( + // Block the install request if the triggering frame does have any cross-origin + // ancestor. + aDetails?.hasCrossOriginAncestor || + // Block the install if triggered by a null principal. + aInstallingPrincipal.isNullPrincipal || + (aBrowser && + (!aBrowser.contentPrincipal || + // When we attempt to handle an XPI load immediately after a + // process switch, the DocShell it's being loaded into will have + // a null principal, since it won't have been initialized yet. + // Allowing installs in this case is relatively safe, since + // there isn't much to gain by spoofing an install request from + // a null principal in any case. This exception can be removed + // once content handlers are triggered by DocumentChannel in the + // parent process. + !( + aBrowser.contentPrincipal.isNullPrincipal || + aInstallingPrincipal.subsumes(aBrowser.contentPrincipal) + ))) + ) { + aInstall.cancel(); + + this.installNotifyObservers( + "addon-install-origin-blocked", + topBrowser, + aInstallingPrincipal.URI, + aInstall + ); + return; + } + + if (aBrowser) { + // The install may start now depending on the web install listener, + // listen for the browser navigating to a new origin and cancel the + // install in that case. + new BrowserListener(aBrowser, aInstallingPrincipal, aInstall); + } + + let startInstall = source => { + AddonManagerInternal.setupPromptHandler( + aBrowser, + aInstallingPrincipal.URI, + aInstall, + true, + source + ); + + AddonManagerInternal.startInstall( + aBrowser, + aInstallingPrincipal.URI, + aInstall + ); + }; + + let installAllowed = this.isInstallAllowed( + aMimetype, + aInstallingPrincipal + ); + let installPerm = Services.perms.testPermissionFromPrincipal( + aInstallingPrincipal, + "install" + ); + + if (installAllowed) { + startInstall("AMO"); + } else if (installPerm === Ci.nsIPermissionManager.DENY_ACTION) { + // Block without prompt + aInstall.cancel(); + this.installNotifyObservers( + "addon-install-blocked-silent", + topBrowser, + aInstallingPrincipal.URI, + aInstall + ); + } else if (!lazy.WEBEXT_POSTDOWNLOAD_THIRD_PARTY) { + // Block with prompt + this.installNotifyObservers( + "addon-install-blocked", + topBrowser, + aInstallingPrincipal.URI, + aInstall, + () => startInstall("other"), + () => aInstall.cancel() + ); + } else { + // We download the addon and validate whether a 3rd party + // install prompt should be shown using e.g. recommended + // state and install_origins. + logger.info(`Addon download before validation.`); + startInstall("other"); + } + } catch (e) { + // In the event that the weblistener throws during instantiation or when + // calling onWebInstallBlocked or onWebInstallRequested the + // install should get cancelled. + logger.warn("Failure calling web installer", e); + aInstall.cancel(); + } + }, + + /** + * Starts installation of an AddonInstall created from add-ons manager + * front-end code (e.g., drag-and-drop of xpis or "Install Add-on from File" + * + * @param browser + * The browser element where the installation was initiated + * @param uri + * The URI of the page where the installation was initiated + * @param install + * The AddonInstall to be installed + */ + installAddonFromAOM(browser, uri, install) { + if (!this.isInstallAllowedByPolicy(null, install)) { + install.cancel(); + + this.installNotifyObservers( + "addon-install-policy-blocked", + browser, + install.sourceURI, + install + ); + return; + } + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + AddonManagerInternal.setupPromptHandler( + browser, + uri, + install, + true, + "local" + ); + AddonManagerInternal.startInstall(browser, uri, install); + }, + + /** + * Adds a new InstallListener if the listener is not already registered. + * + * @param aListener + * The InstallListener to add + */ + addInstallListener(aListener) { + if (!aListener || typeof aListener != "object") { + throw Components.Exception( + "aListener must be a InstallListener object", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.installListeners.add(aListener); + }, + + /** + * Removes an InstallListener if the listener is registered. + * + * @param aListener + * The InstallListener to remove + */ + removeInstallListener(aListener) { + if (!aListener || typeof aListener != "object") { + throw Components.Exception( + "aListener must be a InstallListener object", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.installListeners.delete(aListener); + }, + /** + * Adds new or overrides existing UpgradeListener. + * + * @param aInstanceID + * The instance ID of an addon to register a listener for. + * @param aCallback + * The callback to invoke when updates are available for this addon. + * @throws if there is no addon matching the instanceID + */ + addUpgradeListener(aInstanceID, aCallback) { + if (!aInstanceID || typeof aInstanceID != "symbol") { + throw Components.Exception( + "aInstanceID must be a symbol", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (!aCallback || typeof aCallback != "function") { + throw Components.Exception( + "aCallback must be a function", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let addonId = this.syncGetAddonIDByInstanceID(aInstanceID); + if (!addonId) { + throw Error(`No addon matching instanceID: ${String(aInstanceID)}`); + } + logger.debug(`Registering upgrade listener for ${addonId}`); + this.upgradeListeners.set(addonId, aCallback); + }, + + /** + * Removes an UpgradeListener if the listener is registered. + * + * @param aInstanceID + * The instance ID of the addon to remove + */ + removeUpgradeListener(aInstanceID) { + if (!aInstanceID || typeof aInstanceID != "symbol") { + throw Components.Exception( + "aInstanceID must be a symbol", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let addonId = this.syncGetAddonIDByInstanceID(aInstanceID); + if (!addonId) { + throw Error(`No addon for instanceID: ${aInstanceID}`); + } + if (this.upgradeListeners.has(addonId)) { + this.upgradeListeners.delete(addonId); + } else { + throw Error(`No upgrade listener registered for addon ID: ${addonId}`); + } + }, + + addExternalExtensionLoader(loader) { + this.externalExtensionLoaders.set(loader.name, loader); + }, + + /** + * Installs a temporary add-on from a local file or directory. + * + * @param aFile + * An nsIFile for the file or directory of the add-on to be + * temporarily installed. + * @returns a Promise that rejects if the add-on is not a valid restartless + * add-on or if the same ID is already temporarily installed. + */ + installTemporaryAddon(aFile) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!(aFile instanceof Ci.nsIFile)) { + throw Components.Exception( + "aFile must be a nsIFile", + Cr.NS_ERROR_INVALID_ARG + ); + } + + return AddonManagerInternal._getProviderByName( + "XPIProvider" + ).installTemporaryAddon(aFile); + }, + + /** + * Installs an add-on from a built-in location + * (ie a resource: url referencing assets shipped with the application) + * + * @param aBase + * A string containing the base URL. Must be a resource: URL. + * @returns a Promise that resolves when the addon is installed. + */ + installBuiltinAddon(aBase) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + return AddonManagerInternal._getProviderByName( + "XPIProvider" + ).installBuiltinAddon(aBase); + }, + + /** + * Like `installBuiltinAddon`, but only installs the addon at `aBase` + * if an existing built-in addon with the ID `aID` and version doesn't + * already exist. + * + * @param {string} aID + * The ID of the add-on being registered. + * @param {string} aVersion + * The version of the add-on being registered. + * @param {string} aBase + * A string containing the base URL. Must be a resource: URL. + * @returns a Promise that resolves when the addon is installed. + */ + maybeInstallBuiltinAddon(aID, aVersion, aBase) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + return AddonManagerInternal._getProviderByName( + "XPIProvider" + ).maybeInstallBuiltinAddon(aID, aVersion, aBase); + }, + + syncGetAddonIDByInstanceID(aInstanceID) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!aInstanceID || typeof aInstanceID != "symbol") { + throw Components.Exception( + "aInstanceID must be a Symbol()", + Cr.NS_ERROR_INVALID_ARG + ); + } + + return AddonManagerInternal._getProviderByName( + "XPIProvider" + ).getAddonIDByInstanceID(aInstanceID); + }, + + /** + * Gets an icon from the icon set provided by the add-on + * that is closest to the specified size. + * + * The optional window parameter will be used to determine + * the screen resolution and select a more appropriate icon. + * Calling this method with 48px on retina screens will try to + * match an icon of size 96px. + * + * @param aAddon + * An addon object, meaning: + * An object with either an icons property that is a key-value list + * of icon size and icon URL, or an object having an iconURL property. + * @param aSize + * Ideal icon size in pixels + * @param aWindow + * Optional window object for determining the correct scale. + * @return {String} The absolute URL of the icon or null if the addon doesn't have icons + */ + getPreferredIconURL(aAddon, aSize, aWindow = undefined) { + if (aWindow && aWindow.devicePixelRatio) { + aSize *= aWindow.devicePixelRatio; + } + + let icons = aAddon.icons; + + // certain addon-types only have iconURLs + if (!icons) { + icons = {}; + if (aAddon.iconURL) { + icons[32] = aAddon.iconURL; + icons[48] = aAddon.iconURL; + } + } + + // quick return if the exact size was found + if (icons[aSize]) { + return icons[aSize]; + } + + let bestSize = null; + + for (let size of Object.keys(icons)) { + if (!INTEGER.test(size)) { + throw Components.Exception( + "Invalid icon size, must be an integer", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + size = parseInt(size, 10); + + if (!bestSize) { + bestSize = size; + continue; + } + + if (size > aSize && bestSize > aSize) { + // If both best size and current size are larger than the wanted size then choose + // the one closest to the wanted size + bestSize = Math.min(bestSize, size); + } else { + // Otherwise choose the largest of the two so we'll prefer sizes as close to below aSize + // or above aSize + bestSize = Math.max(bestSize, size); + } + } + + return icons[bestSize] || null; + }, + + /** + * Asynchronously gets an add-on with a specific ID. + * + * @type {function} + * @param {string} aID + * The ID of the add-on to retrieve + * @returns {Promise} resolves with the found Addon or null if no such add-on exists. Never rejects. + * @throws if the aID argument is not specified + */ + getAddonByID(aID) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!aID || typeof aID != "string") { + throw Components.Exception( + "aID must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let promises = Array.from(this.providers, p => + promiseCallProvider(p, "getAddonByID", aID) + ); + return Promise.all(promises).then(aAddons => { + return aAddons.find(a => !!a) || null; + }); + }, + + /** + * Asynchronously get an add-on with a specific Sync GUID. + * + * @param aGUID + * String GUID of add-on to retrieve + * @throws if the aGUID argument is not specified + */ + getAddonBySyncGUID(aGUID) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!aGUID || typeof aGUID != "string") { + throw Components.Exception( + "aGUID must be a non-empty string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + return (async () => { + for (let provider of this.providers) { + let addon = await promiseCallProvider( + provider, + "getAddonBySyncGUID", + aGUID + ); + + if (addon) { + return addon; + } + } + + return null; + })(); + }, + + /** + * Asynchronously gets an array of add-ons. + * + * @param aIDs + * The array of IDs to retrieve + * @return {Promise} + * @resolves The array of found add-ons. + * @rejects Never + * @throws if the aIDs argument is not specified + */ + getAddonsByIDs(aIDs) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!Array.isArray(aIDs)) { + throw Components.Exception( + "aIDs must be an array", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let promises = aIDs.map(a => AddonManagerInternal.getAddonByID(a)); + return Promise.all(promises); + }, + + /** + * Asynchronously gets add-ons of specific types. + * + * @param aTypes + * An optional array of types to retrieve. Each type is a string name + */ + getAddonsByTypes(aTypes) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (aTypes && !Array.isArray(aTypes)) { + throw Components.Exception( + "aTypes must be an array or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + return (async () => { + let addons = []; + + for (let provider of this.providers) { + let providerAddons = await promiseCallProvider( + provider, + "getAddonsByTypes", + aTypes + ); + + if (providerAddons) { + addons.push(...providerAddons); + } + } + + return addons; + })(); + }, + + /** + * Gets active add-ons of specific types. + * + * This is similar to getAddonsByTypes() but it may return a limited + * amount of information about only active addons. Consequently, it + * can be implemented by providers using only immediately available + * data as opposed to getAddonsByTypes which may require I/O). + * + * @param aTypes + * An optional array of types to retrieve. Each type is a string name + * + * @resolve {addons: Array, fullData: bool} + * fullData is true if addons contains all the data we have on those + * addons. It is false if addons only contains partial data. + */ + async getActiveAddons(aTypes) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (aTypes && !Array.isArray(aTypes)) { + throw Components.Exception( + "aTypes must be an array or null", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let addons = [], + fullData = true; + + for (let provider of this.providers) { + let providerAddons, providerFullData; + if ("getActiveAddons" in provider) { + ({ addons: providerAddons, fullData: providerFullData } = + await callProvider(provider, "getActiveAddons", null, aTypes)); + } else { + providerAddons = await promiseCallProvider( + provider, + "getAddonsByTypes", + aTypes + ); + providerAddons = providerAddons.filter(a => a.isActive); + providerFullData = true; + } + + if (providerAddons) { + addons.push(...providerAddons); + fullData = fullData && providerFullData; + } + } + + return { addons, fullData }; + }, + + /** + * Asynchronously gets all installed add-ons. + */ + getAllAddons() { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + return this.getAddonsByTypes(null); + }, + + /** + * Adds a new AddonManagerListener if the listener is not already registered. + * + * @param {AddonManagerListener} aListener + * The listener to add + */ + addManagerListener(aListener) { + if (!aListener || typeof aListener != "object") { + throw Components.Exception( + "aListener must be an AddonManagerListener object", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.managerListeners.add(aListener); + }, + + /** + * Removes an AddonManagerListener if the listener is registered. + * + * @param {AddonManagerListener} aListener + * The listener to remove + */ + removeManagerListener(aListener) { + if (!aListener || typeof aListener != "object") { + throw Components.Exception( + "aListener must be an AddonManagerListener object", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.managerListeners.delete(aListener); + }, + + /** + * Adds a new AddonListener if the listener is not already registered. + * + * @param {AddonManagerListener} aListener + * The AddonListener to add. + */ + addAddonListener(aListener) { + if (!aListener || typeof aListener != "object") { + throw Components.Exception( + "aListener must be an AddonListener object", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.addonListeners.add(aListener); + }, + + /** + * Removes an AddonListener if the listener is registered. + * + * @param {object} aListener + * The AddonListener to remove + */ + removeAddonListener(aListener) { + if (!aListener || typeof aListener != "object") { + throw Components.Exception( + "aListener must be an AddonListener object", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.addonListeners.delete(aListener); + }, + + /** + * @param {string} addonType + * @returns {boolean} + * Whether there is a provider that provides the given addon type. + */ + hasAddonType(addonType) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + for (let addonTypes of this.typesByProvider.values()) { + if (addonTypes.has(addonType)) { + return true; + } + } + return false; + }, + + get autoUpdateDefault() { + return gAutoUpdateDefault; + }, + + set autoUpdateDefault(aValue) { + aValue = !!aValue; + if (aValue != gAutoUpdateDefault) { + Services.prefs.setBoolPref(PREF_EM_AUTOUPDATE_DEFAULT, aValue); + } + }, + + get checkCompatibility() { + return gCheckCompatibility; + }, + + set checkCompatibility(aValue) { + aValue = !!aValue; + if (aValue != gCheckCompatibility) { + if (!aValue) { + Services.prefs.setBoolPref(PREF_EM_CHECK_COMPATIBILITY, false); + } else { + Services.prefs.clearUserPref(PREF_EM_CHECK_COMPATIBILITY); + } + } + }, + + get strictCompatibility() { + return gStrictCompatibility; + }, + + set strictCompatibility(aValue) { + aValue = !!aValue; + if (aValue != gStrictCompatibility) { + Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, aValue); + } + }, + + get checkUpdateSecurityDefault() { + return gCheckUpdateSecurityDefault; + }, + + get checkUpdateSecurity() { + return gCheckUpdateSecurity; + }, + + set checkUpdateSecurity(aValue) { + aValue = !!aValue; + if (aValue != gCheckUpdateSecurity) { + if (aValue != gCheckUpdateSecurityDefault) { + Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, aValue); + } else { + Services.prefs.clearUserPref(PREF_EM_CHECK_UPDATE_SECURITY); + } + } + }, + + get updateEnabled() { + return gUpdateEnabled; + }, + + set updateEnabled(aValue) { + aValue = !!aValue; + if (aValue != gUpdateEnabled) { + Services.prefs.setBoolPref(PREF_EM_UPDATE_ENABLED, aValue); + } + }, + + /** + * Verify whether we need to show the 3rd party install prompt. + * + * Bypass the third party install prompt if this is an install: + * - is an install from a recognized source + * - is a an addon that can bypass the panel, such as a recommended addon + * + * @param {browser} browser browser user is installing from + * @param {nsIURI} url URI for the principal of the installing source + * @param {AddonInstallWrapper} install + * @param {Object} info information such as addon wrapper + * @param {AddonWrapper} info.addon + * @param {string} source simplified string describing source of install and is + * generated based on the installing principal and checking + * against site permissions and enterprise policy. + * It may be one of "AMO", "local" or "other". + * @returns {Promise} Rejected when the installation should not proceed. + */ + _verifyThirdPartyInstall(browser, url, install, info, source) { + // If we are not post-download processing, this panel was already shown. + // Otherwise, if this is from AMO or local, bypass the prompt. + if ( + !lazy.WEBEXT_POSTDOWNLOAD_THIRD_PARTY || + ["AMO", "local"].includes(source) + ) { + return Promise.resolve(); + } + + // verify both the installing source and the xpi url are allowed. + if ( + !info.addon.validInstallOrigins({ + installFrom: url, + source: install.sourceURI, + }) + ) { + install.error = AddonManager.ERROR_INVALID_DOMAIN; + return Promise.reject(); + } + + // Some addons such as recommended addons do not result in this prompt. + if (info.addon.canBypassThirdParyInstallPrompt) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + this.installNotifyObservers( + "addon-install-blocked", + browser, + url, + install, + resolve, + reject + ); + }); + }, + + setupPromptHandler(browser, url, install, requireConfirm, source) { + install.promptHandler = info => + new Promise((resolve, reject) => { + this._verifyThirdPartyInstall(browser, url, install, info, source) + .then(() => { + // All installs end up in this callback when the add-on is available + // for installation. There are numerous different things that can + // happen from here though. For webextensions, if the application + // implements webextension permission prompts, those always take + // precedence. + // If this add-on is not a webextension or if the application does not + // implement permission prompts, no confirmation is displayed for + // installs created from about:addons (in which case requireConfirm + // is false). + // In the remaining cases, a confirmation prompt is displayed but the + // application may override it either by implementing the + // "@mozilla.org/addons/web-install-prompt;1" contract or by setting + // the customConfirmationUI preference and responding to the + // "addon-install-confirmation" notification. If the application + // does not implement its own prompt, use the built-in xul dialog. + if (info.addon.userPermissions) { + let subject = { + wrappedJSObject: { + target: browser, + info: Object.assign({ resolve, reject, source }, info), + }, + }; + subject.wrappedJSObject.info.permissions = + info.addon.userPermissions; + Services.obs.notifyObservers( + subject, + "webextension-permission-prompt" + ); + } else if (info.addon.sitePermissions) { + // Handle prompting for DOM permissions in SitePermission addons. + let { sitePermissions, siteOrigin } = info.addon; + let subject = { + wrappedJSObject: { + target: browser, + info: Object.assign( + { resolve, reject, source, sitePermissions, siteOrigin }, + info + ), + }, + }; + Services.obs.notifyObservers( + subject, + "webextension-permission-prompt" + ); + } else if (requireConfirm) { + // The methods below all want to call the install() or cancel() + // method on the provided AddonInstall object to either accept + // or reject the confirmation. Fit that into our promise-based + // control flow by wrapping the install object. However, + // xpInstallConfirm.xul matches the install object it is passed + // with the argument passed to an InstallListener, so give it + // access to the underlying object through the .wrapped property. + let proxy = new Proxy(install, { + get(target, property) { + if (property == "install") { + return resolve; + } else if (property == "cancel") { + return reject; + } else if (property == "wrapped") { + return target; + } + let result = target[property]; + return typeof result == "function" + ? result.bind(target) + : result; + }, + }); + + // Check for a custom installation prompt that may be provided by the + // applicaton + if ("@mozilla.org/addons/web-install-prompt;1" in Cc) { + try { + let prompt = Cc[ + "@mozilla.org/addons/web-install-prompt;1" + ].getService(Ci.amIWebInstallPrompt); + prompt.confirm(browser, url, [proxy]); + return; + } catch (e) {} + } + + this.installNotifyObservers( + "addon-install-confirmation", + browser, + url, + proxy + ); + } else { + resolve(); + } + }) + .catch(e => { + // Error is undefined if the promise was rejected. + if (e) { + Cu.reportError(`Install prompt handler error: ${e}`); + } + reject(); + }); + }); + }, + + webAPI: { + // installs maps integer ids to AddonInstall instances. + installs: new Map(), + nextInstall: 0, + + sendEvent: null, + setEventHandler(fn) { + this.sendEvent = fn; + }, + + async getAddonByID(target, id) { + return webAPIForAddon(await AddonManager.getAddonByID(id)); + }, + + // helper to copy (and convert) the properties we care about + copyProps(install, obj) { + obj.state = AddonManager.stateToString(install.state); + obj.error = AddonManager.errorToString(install.error); + obj.progress = install.progress; + obj.maxProgress = install.maxProgress; + }, + + forgetInstall(id) { + let info = this.installs.get(id); + if (!info) { + throw new Error(`forgetInstall cannot find ${id}`); + } + info.install.removeListener(info.listener); + this.installs.delete(id); + }, + + createInstall(target, options) { + // Throw an appropriate error if the given URL is not valid + // as an installation source. Return silently if it is okay. + function checkInstallUri(uri) { + if (Services.policies && !Services.policies.allowedInstallSource(uri)) { + // eslint-disable-next-line no-throw-literal + return { + success: false, + code: "addon-install-policy-blocked", + message: `Install from ${uri.spec} not permitted by policy`, + }; + } + + if (WEBAPI_INSTALL_HOSTS.includes(uri.host)) { + return { success: true }; + } + if ( + Services.prefs.getBoolPref(PREF_WEBAPI_TESTING, false) && + WEBAPI_TEST_INSTALL_HOSTS.includes(uri.host) + ) { + return { success: true }; + } + + // eslint-disable-next-line no-throw-literal + return { + success: false, + code: "addon-install-webapi-blocked", + message: `Install from ${uri.host} not permitted`, + }; + } + + const makeListener = (id, mm) => { + const events = [ + "onDownloadStarted", + "onDownloadProgress", + "onDownloadEnded", + "onDownloadCancelled", + "onDownloadFailed", + "onInstallStarted", + "onInstallEnded", + "onInstallCancelled", + "onInstallFailed", + ]; + + let listener = {}; + let installPromise = new Promise((resolve, reject) => { + events.forEach(event => { + listener[event] = (install, addon) => { + let data = { event, id }; + AddonManager.webAPI.copyProps(install, data); + this.sendEvent(mm, data); + if (event == "onInstallEnded") { + resolve(addon); + } else if ( + event == "onDownloadFailed" || + event == "onInstallFailed" + ) { + reject({ message: "install failed" }); + } else if ( + event == "onDownloadCancelled" || + event == "onInstallCancelled" + ) { + reject({ message: "install cancelled" }); + } else if (event == "onDownloadEnded") { + if (install.addon.appDisabled) { + // App disabled items are not compatible and so fail to install + install.cancel(); + AddonManagerInternal.installNotifyObservers( + "addon-install-failed", + target, + Services.io.newURI(options.url), + install + ); + } + } + }; + }); + }); + + // We create the promise here since this is where we're setting + // up the InstallListener, but if the install is never started, + // no handlers will be attached so make sure we terminate errors. + installPromise.catch(() => {}); + + return { listener, installPromise }; + }; + + let uri; + try { + uri = Services.io.newURI(options.url); + const { success, code, message } = checkInstallUri(uri); + if (!success) { + let info = { + wrappedJSObject: { + browser: target, + originatingURI: uri, + installs: [], + }, + }; + Cu.reportError(`${code}: ${message}`); + Services.obs.notifyObservers(info, code); + return Promise.reject({ code, message }); + } + } catch (err) { + // Reject Components.Exception errors (e.g. NS_ERROR_MALFORMED_URI) as is. + if (err instanceof Components.Exception) { + return Promise.reject({ message: err.message }); + } + return Promise.reject({ + message: "Install Failed on unexpected error", + }); + } + + return AddonManagerInternal.getInstallForURL(options.url, { + browser: target, + triggeringPrincipal: options.triggeringPrincipal, + hash: options.hash, + telemetryInfo: { + source: AddonManager.getInstallSourceFromHost(options.sourceHost), + sourceURL: options.sourceURL, + method: "amWebAPI", + }, + }).then(install => { + let requireConfirm = true; + if ( + target.contentDocument && + target.contentDocument.nodePrincipal.isSystemPrincipal + ) { + requireConfirm = false; + } + AddonManagerInternal.setupPromptHandler( + target, + null, + install, + requireConfirm, + "AMO" + ); + + let id = this.nextInstall++; + let { listener, installPromise } = makeListener( + id, + target.messageManager + ); + install.addListener(listener); + + this.installs.set(id, { + install, + target, + listener, + installPromise, + messageManager: target.messageManager, + }); + + let result = { id }; + this.copyProps(install, result); + return result; + }); + }, + + async addonUninstall(target, id) { + let addon = await AddonManager.getAddonByID(id); + if (!addon) { + return false; + } + + if (!(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) { + return Promise.reject({ message: "Addon cannot be uninstalled" }); + } + + try { + addon.uninstall(); + return true; + } catch (err) { + Cu.reportError(err); + return false; + } + }, + + async addonSetEnabled(target, id, value) { + let addon = await AddonManager.getAddonByID(id); + if (!addon) { + throw new Error(`No such addon ${id}`); + } + + if (value) { + await addon.enable(); + } else { + await addon.disable(); + } + }, + + async addonInstallDoInstall(target, id) { + let state = this.installs.get(id); + if (!state) { + throw new Error(`invalid id ${id}`); + } + + let addon = await state.install.install(); + + if (addon.type == "theme" && !addon.appDisabled) { + await addon.enable(); + } + + await new Promise(resolve => { + let subject = { + wrappedJSObject: { target, addon, callback: resolve }, + }; + Services.obs.notifyObservers(subject, "webextension-install-notify"); + }); + }, + + addonInstallCancel(target, id) { + let state = this.installs.get(id); + if (!state) { + return Promise.reject(`invalid id ${id}`); + } + return Promise.resolve(state.install.cancel()); + }, + + clearInstalls(ids) { + for (let id of ids) { + this.forgetInstall(id); + } + }, + + clearInstallsFrom(mm) { + for (let [id, info] of this.installs) { + if (info.messageManager == mm) { + this.forgetInstall(id); + } + } + }, + + async addonReportAbuse(target, id) { + if (!Services.prefs.getBoolPref(PREF_AMO_ABUSEREPORT, false)) { + return Promise.reject({ + message: "amWebAPI reportAbuse not supported", + }); + } + + let existingDialog = lazy.AbuseReporter.getOpenDialog(); + if (existingDialog) { + existingDialog.close(); + } + + const dialog = await lazy.AbuseReporter.openDialog( + id, + "amo", + target + ).catch(err => { + Cu.reportError(err); + return Promise.reject({ + message: "Error creating abuse report", + }); + }); + + return dialog.promiseReport.then( + async report => { + if (!report) { + return false; + } + + await report.submit().catch(err => { + Cu.reportError(err); + return Promise.reject({ + message: "Error submitting abuse report", + }); + }); + + return true; + }, + err => { + Cu.reportError(err); + dialog.close(); + return Promise.reject({ + message: "Error creating abuse report", + }); + } + ); + }, + }, +}; + +/** + * Should not be used outside of core Mozilla code. This is a private API for + * the startup and platform integration code to use. Refer to the methods on + * AddonManagerInternal for documentation however note that these methods are + * subject to change at any time. + */ +export var AddonManagerPrivate = { + startup() { + AddonManagerInternal.startup(); + }, + + addonIsActive(addonId) { + return AddonManagerInternal._getProviderByName("XPIProvider").addonIsActive( + addonId + ); + }, + + /** + * Gets an array of add-ons which were side-loaded prior to the last + * startup, and are currently disabled. + * + * @returns {Promise<Array<Addon>>} + */ + getNewSideloads() { + return AddonManagerInternal._getProviderByName( + "XPIProvider" + ).getNewSideloads(); + }, + + get browserUpdated() { + return gBrowserUpdated; + }, + + registerProvider(aProvider, aTypes) { + AddonManagerInternal.registerProvider(aProvider, aTypes); + }, + + unregisterProvider(aProvider) { + AddonManagerInternal.unregisterProvider(aProvider); + }, + + /** + * Get a list of addon types that was passed to registerProvider for the + * provider with the given name. + * + * @param {string} aProviderName + * @returns {Array<string>} + */ + getAddonTypesByProvider(aProviderName) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + for (let [provider, addonTypes] of AddonManagerInternal.typesByProvider) { + if (providerName(provider) === aProviderName) { + // Return an array because methods such as getAddonsByTypes expect + // aTypes to be an array. + return Array.from(addonTypes); + } + } + throw Components.Exception( + `No addonTypes found for provider: ${aProviderName}`, + Cr.NS_ERROR_INVALID_ARG + ); + }, + + markProviderSafe(aProvider) { + AddonManagerInternal.markProviderSafe(aProvider); + }, + + backgroundUpdateCheck() { + return AddonManagerInternal.backgroundUpdateCheck(); + }, + + backgroundUpdateTimerHandler() { + // Don't return the promise here, since the caller doesn't care. + AddonManagerInternal.backgroundUpdateCheck(); + }, + + addStartupChange(aType, aID) { + AddonManagerInternal.addStartupChange(aType, aID); + }, + + removeStartupChange(aType, aID) { + AddonManagerInternal.removeStartupChange(aType, aID); + }, + + notifyAddonChanged(aID, aType, aPendingRestart) { + return AddonManagerInternal.notifyAddonChanged(aID, aType, aPendingRestart); + }, + + updateAddonAppDisabledStates() { + AddonManagerInternal.updateAddonAppDisabledStates(); + }, + + updateAddonRepositoryData() { + return AddonManagerInternal.updateAddonRepositoryData(); + }, + + callInstallListeners(...aArgs) { + return AddonManagerInternal.callInstallListeners.apply( + AddonManagerInternal, + aArgs + ); + }, + + callAddonListeners(...aArgs) { + AddonManagerInternal.callAddonListeners.apply(AddonManagerInternal, aArgs); + }, + + AddonAuthor, + + AddonScreenshot, + + get BOOTSTRAP_REASONS() { + // BOOTSTRAP_REASONS is a set of constants, and may be accessed before the + // provider has fully been started. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1760146#c1 + return gXPIProvider.BOOTSTRAP_REASONS; + }, + + setAddonStartupData(addonId, startupData) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + // TODO bug 1761079: Ensure that XPIProvider is available before calling it. + gXPIProvider.setStartupData(addonId, startupData); + }, + + unregisterDictionaries(aDicts) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + // TODO bug 1761093: Use _getProviderByName instead of gXPIProvider. + gXPIProvider.unregisterDictionaries(aDicts); + }, + + recordTimestamp(name, value) { + AddonManagerInternal.recordTimestamp(name, value); + }, + + _simpleMeasures: {}, + recordSimpleMeasure(name, value) { + this._simpleMeasures[name] = value; + }, + + recordException(aModule, aContext, aException) { + let report = { + module: aModule, + context: aContext, + }; + + if (typeof aException == "number") { + report.message = Components.Exception("", aException).name; + } else { + report.message = aException.toString(); + if (aException.fileName) { + report.file = aException.fileName; + report.line = aException.lineNumber; + } + } + + this._simpleMeasures.exception = report; + }, + + getSimpleMeasures() { + return this._simpleMeasures; + }, + + getTelemetryDetails() { + return AddonManagerInternal.telemetryDetails; + }, + + setTelemetryDetails(aProvider, aDetails) { + AddonManagerInternal.telemetryDetails[aProvider] = aDetails; + }, + + // Start a timer, record a simple measure of the time interval when + // timer.done() is called + simpleTimer(aName) { + let startTime = Cu.now(); + return { + done: () => + this.recordSimpleMeasure(aName, Math.round(Cu.now() - startTime)), + }; + }, + + async recordTiming(name, task) { + let timer = this.simpleTimer(name); + try { + return await task(); + } finally { + timer.done(); + } + }, + + /** + * Helper to call update listeners when no update is available. + * + * This can be used as an implementation for Addon.findUpdates() when + * no update mechanism is available. + */ + callNoUpdateListeners(addon, listener, reason, appVersion, platformVersion) { + if ("onNoCompatibilityUpdateAvailable" in listener) { + safeCall(listener.onNoCompatibilityUpdateAvailable.bind(listener), addon); + } + if ("onNoUpdateAvailable" in listener) { + safeCall(listener.onNoUpdateAvailable.bind(listener), addon); + } + if ("onUpdateFinished" in listener) { + safeCall(listener.onUpdateFinished.bind(listener), addon); + } + }, + + get webExtensionsMinPlatformVersion() { + return gWebExtensionsMinPlatformVersion; + }, + + hasUpgradeListener(aId) { + return AddonManagerInternal.upgradeListeners.has(aId); + }, + + getUpgradeListener(aId) { + return AddonManagerInternal.upgradeListeners.get(aId); + }, + + get externalExtensionLoaders() { + return AddonManagerInternal.externalExtensionLoaders; + }, + + /** + * Predicate that returns true if we think the given extension ID + * might have been generated by XPIProvider. + */ + isTemporaryInstallID(extensionId) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + + if (!extensionId || typeof extensionId != "string") { + throw Components.Exception( + "extensionId must be a string", + Cr.NS_ERROR_INVALID_ARG + ); + } + + return AddonManagerInternal._getProviderByName( + "XPIProvider" + ).isTemporaryInstallID(extensionId); + }, + + isDBLoaded() { + let provider = AddonManagerInternal._getProviderByName("XPIProvider"); + return provider ? provider.isDBLoaded : false; + }, + + get databaseReady() { + let provider = AddonManagerInternal._getProviderByName("XPIProvider"); + return provider ? provider.databaseReady : new Promise(() => {}); + }, + + /** + * Async shutdown barrier which blocks the completion of add-on + * manager shutdown. This should generally only be used by add-on + * providers (i.e., XPIProvider) to complete their final shutdown + * tasks. + */ + get finalShutdown() { + return gFinalShutdownBarrier.client; + }, + + // Used by tests to call repo shutdown. + overrideAddonRepository(mockRepo) { + lazy.AddonRepository = mockRepo; + }, + + // Used by tests to shut down AddonManager. + overrideAsyncShutdown(mockAsyncShutdown) { + AsyncShutdown = mockAsyncShutdown; + }, +}; + +/** + * This is the public API that UI and developers should be calling. All methods + * just forward to AddonManagerInternal. + * @class + */ +export var AddonManager = { + // Map used to convert the known install source hostnames into the value to set into the + // telemetry events. + _installHostSource: new Map([ + ["addons.mozilla.org", "amo"], + ["discovery.addons.mozilla.org", "disco"], + ]), + + // Constants for the AddonInstall.state property + // These will show up as AddonManager.STATE_* (eg, STATE_AVAILABLE) + _states: new Map([ + // The install is available for download. + ["STATE_AVAILABLE", 0], + // The install is being downloaded. + ["STATE_DOWNLOADING", 1], + // The install is checking the update for compatibility information. + ["STATE_CHECKING_UPDATE", 2], + // The install is downloaded and ready to install. + ["STATE_DOWNLOADED", 3], + // The download failed. + ["STATE_DOWNLOAD_FAILED", 4], + // The install may not proceed until the user accepts a prompt + ["STATE_AWAITING_PROMPT", 5], + // Any prompts are done + ["STATE_PROMPTS_DONE", 6], + // The install has been postponed. + ["STATE_POSTPONED", 7], + // The install is ready to be applied. + ["STATE_READY", 8], + // The add-on is being installed. + ["STATE_INSTALLING", 9], + // The add-on has been installed. + ["STATE_INSTALLED", 10], + // The install failed. + ["STATE_INSTALL_FAILED", 11], + // The install has been cancelled. + ["STATE_CANCELLED", 12], + ]), + + // Constants representing different types of errors while downloading an + // add-on as a preparation for installation. + // These will show up as AddonManager.ERROR_* (eg, ERROR_NETWORK_FAILURE) + // The _errors codes are translated to text for a panel in browser-addons.js. + // The localized messages are located in extensionsUI.ftl. + // Errors with the "Updates only:" prefix are not translated + // because the error is dumped to the console instead of a panel. + _errors: new Map([ + // The download failed due to network problems. + ["ERROR_NETWORK_FAILURE", -1], + // The downloaded file did not match the provided hash. + ["ERROR_INCORRECT_HASH", -2], + // The downloaded file seems to be corrupted in some way. + ["ERROR_CORRUPT_FILE", -3], + // An error occurred trying to write to the filesystem. + ["ERROR_FILE_ACCESS", -4], + // The add-on must be signed and isn't. + ["ERROR_SIGNEDSTATE_REQUIRED", -5], + // Updates only: The downloaded add-on had a different type than expected. + ["ERROR_UNEXPECTED_ADDON_TYPE", -6], + // Updates only: The addon did not have the expected ID. + ["ERROR_INCORRECT_ID", -7], + // The addon install_origins does not list the 3rd party domain. + ["ERROR_INVALID_DOMAIN", -8], + // Updates only: The downloaded add-on had a different version than expected. + ["ERROR_UNEXPECTED_ADDON_VERSION", -9], + ]), + // The update check timed out + ERROR_TIMEOUT: -1, + // There was an error while downloading the update information. + ERROR_DOWNLOAD_ERROR: -2, + // The update information was malformed in some way. + ERROR_PARSE_ERROR: -3, + // The update information was not in any known format. + ERROR_UNKNOWN_FORMAT: -4, + // The update information was not correctly signed or there was an SSL error. + ERROR_SECURITY_ERROR: -5, + // The update was cancelled + ERROR_CANCELLED: -6, + // These must be kept in sync with AddonUpdateChecker. + // No error was encountered. + UPDATE_STATUS_NO_ERROR: 0, + // The update check timed out + UPDATE_STATUS_TIMEOUT: -1, + // There was an error while downloading the update information. + UPDATE_STATUS_DOWNLOAD_ERROR: -2, + // The update information was malformed in some way. + UPDATE_STATUS_PARSE_ERROR: -3, + // The update information was not in any known format. + UPDATE_STATUS_UNKNOWN_FORMAT: -4, + // The update information was not correctly signed or there was an SSL error. + UPDATE_STATUS_SECURITY_ERROR: -5, + // The update was cancelled. + UPDATE_STATUS_CANCELLED: -6, + // Constants to indicate why an update check is being performed + // Update check has been requested by the user. + UPDATE_WHEN_USER_REQUESTED: 1, + // Update check is necessary to see if the Addon is compatibile with a new + // version of the application. + UPDATE_WHEN_NEW_APP_DETECTED: 2, + // Update check is necessary because a new application has been installed. + UPDATE_WHEN_NEW_APP_INSTALLED: 3, + // Update check is a regular background update check. + UPDATE_WHEN_PERIODIC_UPDATE: 16, + // Update check is needed to check an Addon that is being installed. + UPDATE_WHEN_ADDON_INSTALLED: 17, + + // Constants for operations in Addon.pendingOperations + // Indicates that the Addon has no pending operations. + PENDING_NONE: 0, + // Indicates that the Addon will be enabled after the application restarts. + PENDING_ENABLE: 1, + // Indicates that the Addon will be disabled after the application restarts. + PENDING_DISABLE: 2, + // Indicates that the Addon will be uninstalled after the application restarts. + PENDING_UNINSTALL: 4, + // Indicates that the Addon will be installed after the application restarts. + PENDING_INSTALL: 8, + PENDING_UPGRADE: 16, + + // Constants for operations in Addon.operationsRequiringRestart + // Indicates that restart isn't required for any operation. + OP_NEEDS_RESTART_NONE: 0, + // Indicates that restart is required for enabling the addon. + OP_NEEDS_RESTART_ENABLE: 1, + // Indicates that restart is required for disabling the addon. + OP_NEEDS_RESTART_DISABLE: 2, + // Indicates that restart is required for uninstalling the addon. + OP_NEEDS_RESTART_UNINSTALL: 4, + // Indicates that restart is required for installing the addon. + OP_NEEDS_RESTART_INSTALL: 8, + + // Constants for permissions in Addon.permissions. + // Indicates that the Addon can be uninstalled. + PERM_CAN_UNINSTALL: 1, + // Indicates that the Addon can be enabled by the user. + PERM_CAN_ENABLE: 2, + // Indicates that the Addon can be disabled by the user. + PERM_CAN_DISABLE: 4, + // Indicates that the Addon can be upgraded. + PERM_CAN_UPGRADE: 8, + // Indicates that the Addon can be set to be allowed/disallowed + // in private browsing windows. + PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS: 32, + // Indicates that internal APIs can uninstall the add-on, even if the + // front-end cannot. + PERM_API_CAN_UNINSTALL: 64, + + // General descriptions of where items are installed. + // Installed in this profile. + SCOPE_PROFILE: 1, + // Installed for all of this user's profiles. + SCOPE_USER: 2, + // Installed and owned by the application. + SCOPE_APPLICATION: 4, + // Installed for all users of the computer. + SCOPE_SYSTEM: 8, + // Installed temporarily + SCOPE_TEMPORARY: 16, + // The combination of all scopes. + SCOPE_ALL: 31, + + // Constants for Addon.applyBackgroundUpdates. + // Indicates that the Addon should not update automatically. + AUTOUPDATE_DISABLE: 0, + // Indicates that the Addon should update automatically only if + // that's the global default. + AUTOUPDATE_DEFAULT: 1, + // Indicates that the Addon should update automatically. + AUTOUPDATE_ENABLE: 2, + + // Constants for how Addon options should be shown. + // Options will be displayed in a new tab, if possible + OPTIONS_TYPE_TAB: 3, + // Similar to OPTIONS_TYPE_INLINE, but rather than generating inline + // options from a specially-formatted XUL file, the contents of the + // file are simply displayed in an inline <browser> element. + OPTIONS_TYPE_INLINE_BROWSER: 5, + + // Constants for displayed or hidden options notifications + // Options notification will be displayed + OPTIONS_NOTIFICATION_DISPLAYED: "addon-options-displayed", + // Options notification will be hidden + OPTIONS_NOTIFICATION_HIDDEN: "addon-options-hidden", + + // Constants for getStartupChanges, addStartupChange and removeStartupChange + // Add-ons that were detected as installed during startup. Doesn't include + // add-ons that were pending installation the last time the application ran. + STARTUP_CHANGE_INSTALLED: "installed", + // Add-ons that were detected as changed during startup. This includes an + // add-on moving to a different location, changing version or just having + // been detected as possibly changed. + STARTUP_CHANGE_CHANGED: "changed", + // Add-ons that were detected as uninstalled during startup. Doesn't include + // add-ons that were pending uninstallation the last time the application ran. + STARTUP_CHANGE_UNINSTALLED: "uninstalled", + // Add-ons that were detected as disabled during startup, normally because of + // an application change making an add-on incompatible. Doesn't include + // add-ons that were pending being disabled the last time the application ran. + STARTUP_CHANGE_DISABLED: "disabled", + // Add-ons that were detected as enabled during startup, normally because of + // an application change making an add-on compatible. Doesn't include + // add-ons that were pending being enabled the last time the application ran. + STARTUP_CHANGE_ENABLED: "enabled", + + // Constants for Addon.signedState. Any states that should cause an add-on + // to be unusable in builds that require signing should have negative values. + // Add-on signing is not required, e.g. because the pref is disabled. + SIGNEDSTATE_NOT_REQUIRED: undefined, + // Add-on is signed but signature verification has failed. + SIGNEDSTATE_BROKEN: -2, + // Add-on may be signed but by an certificate that doesn't chain to our + // our trusted certificate. + SIGNEDSTATE_UNKNOWN: -1, + // Add-on is unsigned. + SIGNEDSTATE_MISSING: 0, + // Add-on is preliminarily reviewed. + SIGNEDSTATE_PRELIMINARY: 1, + // Add-on is fully reviewed. + SIGNEDSTATE_SIGNED: 2, + // Add-on is system add-on. + SIGNEDSTATE_SYSTEM: 3, + // Add-on is signed with a "Mozilla Extensions" certificate + SIGNEDSTATE_PRIVILEGED: 4, + + get __AddonManagerInternal__() { + return AppConstants.DEBUG ? AddonManagerInternal : undefined; + }, + + /** Boolean indicating whether AddonManager startup has completed. */ + get isReady() { + return gStartupComplete && !gShutdownInProgress; + }, + + /** + * A promise that is resolved when the AddonManager startup has completed. + * This may be rejected if startup of the AddonManager is not successful, or + * if shutdown is started before the AddonManager has finished starting. + */ + get readyPromise() { + return gStartedPromise.promise; + }, + + /** @constructor */ + init() { + this._stateToString = new Map(); + for (let [name, value] of this._states) { + this[name] = value; + this._stateToString.set(value, name); + } + this._errorToString = new Map(); + for (let [name, value] of this._errors) { + this[name] = value; + this._errorToString.set(value, name); + } + }, + + stateToString(state) { + return this._stateToString.get(state); + }, + + errorToString(err) { + return err ? this._errorToString.get(err) : null; + }, + + getInstallSourceFromHost(host) { + if (this._installHostSource.has(host)) { + return this._installHostSource.get(host); + } + + if (WEBAPI_TEST_INSTALL_HOSTS.includes(host)) { + return "test-host"; + } + + return "unknown"; + }, + + getInstallForURL(aUrl, aOptions) { + return AddonManagerInternal.getInstallForURL(aUrl, aOptions); + }, + + getInstallForFile( + aFile, + aMimetype, + aTelemetryInfo, + aUseSystemLocation = false + ) { + return AddonManagerInternal.getInstallForFile( + aFile, + aMimetype, + aTelemetryInfo, + aUseSystemLocation + ); + }, + + uninstallSystemProfileAddon(aID) { + return AddonManagerInternal.uninstallSystemProfileAddon(aID); + }, + + stageLangpacksForAppUpdate(appVersion, platformVersion) { + return AddonManagerInternal._getProviderByName( + "XPIProvider" + ).stageLangpacksForAppUpdate(appVersion, platformVersion); + }, + + /** + * Gets an array of add-on IDs that changed during the most recent startup. + * + * @param aType + * The type of startup change to get + * @return An array of add-on IDs + */ + getStartupChanges(aType) { + if (!(aType in AddonManagerInternal.startupChanges)) { + return []; + } + return AddonManagerInternal.startupChanges[aType].slice(0); + }, + + getAddonByID(aID) { + return AddonManagerInternal.getAddonByID(aID); + }, + + getAddonBySyncGUID(aGUID) { + return AddonManagerInternal.getAddonBySyncGUID(aGUID); + }, + + getAddonsByIDs(aIDs) { + return AddonManagerInternal.getAddonsByIDs(aIDs); + }, + + getAddonsByTypes(aTypes) { + return AddonManagerInternal.getAddonsByTypes(aTypes); + }, + + getActiveAddons(aTypes) { + return AddonManagerInternal.getActiveAddons(aTypes); + }, + + getAllAddons() { + return AddonManagerInternal.getAllAddons(); + }, + + getInstallsByTypes(aTypes) { + return AddonManagerInternal.getInstallsByTypes(aTypes); + }, + + getAllInstalls() { + return AddonManagerInternal.getAllInstalls(); + }, + + isInstallEnabled(aType) { + return AddonManagerInternal.isInstallEnabled(aType); + }, + + isInstallAllowed(aType, aInstallingPrincipal) { + return AddonManagerInternal.isInstallAllowed(aType, aInstallingPrincipal); + }, + + installSitePermsAddonFromWebpage( + aBrowser, + aInstallingPrincipal, + aPermission + ) { + return AddonManagerInternal.installSitePermsAddonFromWebpage( + aBrowser, + aInstallingPrincipal, + aPermission + ); + }, + + installAddonFromWebpage( + aType, + aBrowser, + aInstallingPrincipal, + aInstall, + details + ) { + AddonManagerInternal.installAddonFromWebpage( + aType, + aBrowser, + aInstallingPrincipal, + aInstall, + details + ); + }, + + installAddonFromAOM(aBrowser, aUri, aInstall) { + AddonManagerInternal.installAddonFromAOM(aBrowser, aUri, aInstall); + }, + + installTemporaryAddon(aDirectory) { + return AddonManagerInternal.installTemporaryAddon(aDirectory); + }, + + installBuiltinAddon(aBase) { + return AddonManagerInternal.installBuiltinAddon(aBase); + }, + + maybeInstallBuiltinAddon(aID, aVersion, aBase) { + return AddonManagerInternal.maybeInstallBuiltinAddon(aID, aVersion, aBase); + }, + + addManagerListener(aListener) { + AddonManagerInternal.addManagerListener(aListener); + }, + + removeManagerListener(aListener) { + AddonManagerInternal.removeManagerListener(aListener); + }, + + addInstallListener(aListener) { + AddonManagerInternal.addInstallListener(aListener); + }, + + removeInstallListener(aListener) { + AddonManagerInternal.removeInstallListener(aListener); + }, + + getUpgradeListener(aId) { + return AddonManagerInternal.upgradeListeners.get(aId); + }, + + addUpgradeListener(aInstanceID, aCallback) { + AddonManagerInternal.addUpgradeListener(aInstanceID, aCallback); + }, + + removeUpgradeListener(aInstanceID) { + return AddonManagerInternal.removeUpgradeListener(aInstanceID); + }, + + addExternalExtensionLoader(loader) { + return AddonManagerInternal.addExternalExtensionLoader(loader); + }, + + addAddonListener(aListener) { + AddonManagerInternal.addAddonListener(aListener); + }, + + removeAddonListener(aListener) { + AddonManagerInternal.removeAddonListener(aListener); + }, + + hasAddonType(addonType) { + return AddonManagerInternal.hasAddonType(addonType); + }, + + hasProvider(name) { + if (!gStarted) { + throw Components.Exception( + "AddonManager is not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + return !!AddonManagerInternal._getProviderByName(name); + }, + + /** + * Determines whether an Addon should auto-update or not. + * + * @param aAddon + * The Addon representing the add-on + * @return true if the addon should auto-update, false otherwise. + */ + shouldAutoUpdate(aAddon) { + if (!aAddon || typeof aAddon != "object") { + throw Components.Exception( + "aAddon must be specified", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // Special case colorway built-in themes being migrated to an AMO installed theme + // when an update was found and: + // + // - `extensions.update.enable` is set to true (and so add-on updates are still + // being checked automatically on the background) + // - `extensions.update.autoUpdateDefault` is set to false (likely because the + // user has disabled auto-applying add-ons updates in about:addons to review + // extensions changelogs before accepting an update, e.g. to avoid unexpected + // issues that a new version of an extension may be introducing in the update) + // + // TODO(Bug 1815898): remove this special case along with other AOM/XPIProvider + // special cases introduced for colorways themes or colorways migration. + if (aAddon.isBuiltinColorwayTheme) { + return true; + } + + if (!("applyBackgroundUpdates" in aAddon)) { + return false; + } + if (!(aAddon.permissions & AddonManager.PERM_CAN_UPGRADE)) { + return false; + } + if (aAddon.applyBackgroundUpdates == AddonManager.AUTOUPDATE_ENABLE) { + return true; + } + if (aAddon.applyBackgroundUpdates == AddonManager.AUTOUPDATE_DISABLE) { + return false; + } + return this.autoUpdateDefault; + }, + + get checkCompatibility() { + return AddonManagerInternal.checkCompatibility; + }, + + set checkCompatibility(aValue) { + AddonManagerInternal.checkCompatibility = aValue; + }, + + get strictCompatibility() { + return AddonManagerInternal.strictCompatibility; + }, + + set strictCompatibility(aValue) { + AddonManagerInternal.strictCompatibility = aValue; + }, + + get checkUpdateSecurityDefault() { + return AddonManagerInternal.checkUpdateSecurityDefault; + }, + + get checkUpdateSecurity() { + return AddonManagerInternal.checkUpdateSecurity; + }, + + set checkUpdateSecurity(aValue) { + AddonManagerInternal.checkUpdateSecurity = aValue; + }, + + get updateEnabled() { + return AddonManagerInternal.updateEnabled; + }, + + set updateEnabled(aValue) { + AddonManagerInternal.updateEnabled = aValue; + }, + + get autoUpdateDefault() { + return AddonManagerInternal.autoUpdateDefault; + }, + + set autoUpdateDefault(aValue) { + AddonManagerInternal.autoUpdateDefault = aValue; + }, + + escapeAddonURI(aAddon, aUri, aAppVersion) { + return AddonManagerInternal.escapeAddonURI(aAddon, aUri, aAppVersion); + }, + + getPreferredIconURL(aAddon, aSize, aWindow = undefined) { + return AddonManagerInternal.getPreferredIconURL(aAddon, aSize, aWindow); + }, + + get webAPI() { + return AddonManagerInternal.webAPI; + }, + + /** + * Async shutdown barrier which blocks the start of AddonManager + * shutdown. Callers should add blockers to this barrier if they need + * to complete add-on manager operations before it shuts down. + */ + get beforeShutdown() { + return gBeforeShutdownBarrier.client; + }, +}; + +/** + * Manage AddonManager settings propagated over RemoteSettings synced data. + * + * See :doc:`AMRemoteSettings Overview <AMRemoteSettings-overview>`. + * + * .. warning:: + * Before landing any change to ``AMRemoteSettings`` or the format expected for the + * remotely controlled settings (on the service or Firefos side), please read the + * documentation page linked above and make sure to keep the JSON Schema described + * and controlled settings groups included in that documentation page in sync with + * the one actually set on the RemoteSettings service side. + */ +AMRemoteSettings = { + RS_COLLECTION: "addons-manager-settings", + + /** + * RemoteSettings settings group map. + * + * .. note:: + * Please keep in sync the "Controlled Settings Groups" documentation from + * :doc:`AMRemoteSettings Overview <AMRemoteSettings-overview>` in sync with + * the settings groups defined here. + */ + RS_ENTRIES_MAP: { + installTriggerDeprecation: [ + "extensions.InstallTriggerImpl.enabled", + "extensions.InstallTrigger.enabled", + ], + quarantinedDomains: ["extensions.quarantinedDomains.list"], + }, + + client: null, + onSync: null, + promiseStartup: null, + + init() { + try { + if (!this.promiseStartup) { + // Creating a promise to resolved when the browser startup was completed, + // used to process the existing entries (if any) after the startup is completed + // and to only to it ones. + this.promiseStartup = new Promise(resolve => { + function observer() { + resolve(); + Services.obs.removeObserver( + observer, + "browser-delayed-startup-finished" + ); + } + Services.obs.addObserver( + observer, + "browser-delayed-startup-finished" + ); + }); + } + + if (Services.prefs.getBoolPref(PREF_REMOTESETTINGS_DISABLED, false)) { + return; + } + + if (!this.client) { + this.client = lazy.RemoteSettings(this.RS_COLLECTION); + this.onSync = this.processEntries.bind(this); + this.client.on("sync", this.onSync); + // Process existing entries if any, once the browser has been fully initialized. + this.promiseStartup.then(() => this.processEntries()); + } + } catch (err) { + logger.error("Failure to initialize AddonManager RemoteSettings", err); + } + }, + + shutdown() { + try { + if (this.client) { + this.client.off("sync", this.onSync); + this.client = null; + this.onSync = null; + } + this.promiseStartup = null; + } catch (err) { + logger.error("Failure on shutdown AddonManager RemoteSettings", err); + } + }, + + /** + * Process all the settings groups that are included in the collection entry with ``"id"`` set to ``"AddonManagerSettings"`` + * (if any). + * + * .. note:: + * This method may need to be updated if the preference value type is not yet expected by this method + * (which means that it would be ignored until handled explicitly). + */ + async processEntries() { + const entries = await this.client.get({ syncIfEmpty: false }).catch(err => { + logger.error("Failure to process AddonManager RemoteSettings", err); + return []; + }); + + const processEntryPref = (entryId, groupName, prefName, prefValue) => { + try { + logger.debug( + `Process AddonManager RemoteSettings "${entryId}" - "${groupName}": ${prefName}` + ); + + // Support for controlling boolean and string AddonManager settings. + switch (typeof prefValue) { + case "boolean": + Services.prefs.setBoolPref(prefName, prefValue); + break; + case "string": + Services.prefs.setStringPref(prefName, prefValue); + break; + default: + throw new Error(`Unexpected type ${typeof prefValue}`); + } + } catch (e) { + logger.error( + `Failed to process AddonManager RemoteSettings "${entryId}" - "${groupName}": ${prefName}`, + e + ); + } + }; + + for (const entry of entries) { + logger.debug(`Processing AddonManager RemoteSettings "${entry.id}"`); + + for (const [groupName, prefs] of Object.entries(this.RS_ENTRIES_MAP)) { + const data = entry[groupName]; + if (!data) { + continue; + } + + for (const pref of prefs) { + // Skip the pref if it is not included in the remote settings data. + if (!(pref in data)) { + continue; + } + + processEntryPref(entry.id, groupName, pref, data[pref]); + } + } + } + }, +}; + +/** + * Listens to the AddonManager install and addon events and send telemetry events. + */ +AMTelemetry = { + telemetrySetupDone: false, + + init() { + // Enable the addonsManager telemetry event category before the AddonManager + // has completed its startup, otherwise telemetry events recorded during the + // AddonManager/XPIProvider startup will not be recorded. + Services.telemetry.setEventRecordingEnabled("addonsManager", true); + }, + + // This method is called by the AddonManager, once it has been started, so that we can + // init the telemetry event category and start listening for the events related to the + // addons installation and management. + onStartup() { + if (this.telemetrySetupDone) { + return; + } + + this.telemetrySetupDone = true; + + Services.obs.addObserver(this, "addon-install-origin-blocked"); + Services.obs.addObserver(this, "addon-install-disabled"); + Services.obs.addObserver(this, "addon-install-blocked"); + + AddonManager.addInstallListener(this); + AddonManager.addAddonListener(this); + }, + + // Observer Service notification callback. + + observe(subject, topic, data) { + switch (topic) { + case "addon-install-blocked": { + const { installs } = subject.wrappedJSObject; + this.recordInstallEvent(installs[0], { step: "site_warning" }); + break; + } + case "addon-install-origin-blocked": { + const { installs } = subject.wrappedJSObject; + this.recordInstallEvent(installs[0], { step: "site_blocked" }); + break; + } + case "addon-install-disabled": { + const { installs } = subject.wrappedJSObject; + this.recordInstallEvent(installs[0], { + step: "install_disabled_warning", + }); + break; + } + } + }, + + // AddonManager install listener callbacks. + + onNewInstall(install) { + this.recordInstallEvent(install, { step: "started" }); + }, + + onInstallCancelled(install) { + this.recordInstallEvent(install, { step: "cancelled" }); + }, + + onInstallPostponed(install) { + this.recordInstallEvent(install, { step: "postponed" }); + }, + + onInstallFailed(install) { + this.recordInstallEvent(install, { step: "failed" }); + }, + + onInstallEnded(install) { + this.recordInstallEvent(install, { step: "completed" }); + // Skip install_stats events for install objects related to. + // add-on updates. + if (!install.existingAddon) { + this.recordInstallStatsEvent(install); + } + }, + + onDownloadStarted(install) { + this.recordInstallEvent(install, { step: "download_started" }); + }, + + onDownloadCancelled(install) { + this.recordInstallEvent(install, { step: "cancelled" }); + }, + + onDownloadEnded(install) { + let download_time = Math.round(Cu.now() - install.downloadStartedAt); + this.recordInstallEvent(install, { + step: "download_completed", + download_time, + }); + }, + + onDownloadFailed(install) { + let download_time = Math.round(Cu.now() - install.downloadStartedAt); + this.recordInstallEvent(install, { + step: "download_failed", + download_time, + }); + }, + + // Addon listeners callbacks. + + onUninstalled(addon) { + this.recordManageEvent(addon, "uninstall"); + }, + + onEnabled(addon) { + this.recordManageEvent(addon, "enable"); + }, + + onDisabled(addon) { + this.recordManageEvent(addon, "disable"); + }, + + // Internal helpers methods. + + /** + * Get a trimmed version of the given string if it is longer than 80 chars. + * + * @param {string} str + * The original string content. + * + * @returns {string} + * The trimmed version of the string when longer than 80 chars, or the given string + * unmodified otherwise. + */ + getTrimmedString(str) { + if (str.length <= 80) { + return str; + } + + const length = str.length; + + // Trim the string to prevent a flood of warnings messages logged internally by recordEvent, + // the trimmed version is going to be composed by the first 40 chars and the last 37 and 3 dots + // that joins the two parts, to visually indicate that the string has been trimmed. + return `${str.slice(0, 40)}...${str.slice(length - 37, length)}`; + }, + + /** + * Retrieve the addonId for the given AddonInstall instance. + * + * @param {AddonInstall} install + * The AddonInstall instance to retrieve the addonId from. + * + * @returns {string | null} + * The addonId for the given AddonInstall instance (if any). + */ + getAddonIdFromInstall(install) { + // Returns the id of the extension that is being installed, as soon as the + // addon is available in the AddonInstall instance (after being downloaded + // and validated successfully). + if (install.addon) { + return install.addon.id; + } + + // While updating an addon, the existing addon can be + // used to retrieve the addon id since the first update event. + if (install.existingAddon) { + return install.existingAddon.id; + } + + return null; + }, + + /** + * Retrieve the telemetry event's object property value for the given + * AddonInstall instance. + * + * @param {AddonInstall} install + * The AddonInstall instance to retrieve the event object from. + * + * @returns {string} + * The object for the given AddonInstall instance. + */ + getEventObjectFromInstall(install) { + let addonType; + + if (install.type) { + // The AddonInstall wrapper already provides a type (if it was known when the + // install object has been created). + addonType = install.type; + } else if (install.addon) { + // The install flow has reached a step that has an addon instance which we can + // check to know the extension type (e.g. after download for the DownloadAddonInstall). + addonType = install.addon.type; + } else if (install.existingAddon) { + // The install flow is an update and we can look the existingAddon to check which was + // the add-on type that is being installed. + addonType = install.existingAddon.type; + } + + return this.getEventObjectFromAddonType(addonType); + }, + + /** + * Retrieve the telemetry event source for the given AddonInstall instance. + * + * @param {AddonInstall} install + * The AddonInstall instance to retrieve the source from. + * + * @returns {Object | null} + * The telemetry infor ({source, method}) from the given AddonInstall instance. + */ + getInstallTelemetryInfo(install) { + if (install.installTelemetryInfo) { + return install.installTelemetryInfo; + } else if ( + install.existingAddon && + install.existingAddon.installTelemetryInfo + ) { + // Get the install source from the existing addon (e.g. for an extension update). + return install.existingAddon.installTelemetryInfo; + } + + return null; + }, + + /** + * Get the telemetry event's object property for the given addon type + * + * @param {string} addonType + * The addon type to convert into the related telemetry event object. + * + * @returns {string} + * The object for the given addon type. + */ + getEventObjectFromAddonType(addonType) { + switch (addonType) { + case undefined: + return "unknown"; + case "extension": + case "theme": + case "locale": + case "dictionary": + case "sitepermission": + return addonType; + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. + case "sitepermission-deprecated": + // Telemetry events' object maximum length is 20 chars (See https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/collection/events.html#limits) + // and the value needs to matching the "^[a-zA-Z][a-zA-Z0-9_.]*[a-zA-Z0-9]$" pattern. + return "siteperm_deprecated"; + default: + // Currently this should only include gmp-plugins ("plugin"). + return "other"; + } + }, + + convertToString(value) { + if (value == null) { + // Convert null and undefined to empty strings. + return ""; + } + switch (typeof value) { + case "string": + return value; + case "boolean": + return value ? "1" : "0"; + } + return String(value); + }, + + /** + * Return the UTM parameters found in `sourceURL` for AMO attribution data. + * + * @param {string} sourceURL + * The source URL from where the add-on has been installed. + * + * @returns {object} + * An object containing the attribution data for AMO if any. Keys + * are defined in `AMO_ATTRIBUTION_DATA_KEYS`. Values are strings. + */ + parseAttributionDataForAMO(sourceURL) { + let searchParams; + + try { + searchParams = new URL(sourceURL).searchParams; + } catch { + return {}; + } + + const utmKeys = [...searchParams.keys()].filter(key => + AMO_ATTRIBUTION_DATA_KEYS.includes(key) + ); + + return utmKeys.reduce((params, key) => { + let value = searchParams.get(key); + if (typeof value === "string") { + value = value.slice(0, AMO_ATTRIBUTION_DATA_MAX_LENGTH); + } + + return { ...params, [key]: value }; + }, {}); + }, + + /** + * Record an "install stats" event when the source is included in + * `AMO_ATTRIBUTION_ALLOWED_SOURCES`. + * + * @param {AddonInstall} install + * The AddonInstall instance to record an install_stats event for. + */ + recordInstallStatsEvent(install) { + const telemetryInfo = this.getInstallTelemetryInfo(install); + + if (!AMO_ATTRIBUTION_ALLOWED_SOURCES.includes(telemetryInfo?.source)) { + return; + } + + const method = "install_stats"; + const object = this.getEventObjectFromInstall(install); + const addonId = this.getAddonIdFromInstall(install); + + if (!addonId) { + Cu.reportError( + "Missing addonId when trying to record an install_stats event" + ); + return; + } + + let extra = { + addon_id: this.getTrimmedString(addonId), + }; + + if ( + telemetryInfo?.source === "amo" && + typeof telemetryInfo?.sourceURL === "string" + ) { + extra = { + ...extra, + ...this.parseAttributionDataForAMO(telemetryInfo.sourceURL), + }; + } + + if ( + telemetryInfo?.source === "disco" && + typeof telemetryInfo?.taarRecommended === "boolean" + ) { + extra = { + ...extra, + taar_based: this.convertToString(telemetryInfo.taarRecommended), + }; + } + + this.recordEvent({ method, object, value: install.hashedAddonId, extra }); + }, + + /** + * Convert all the telemetry event's extra_vars into strings, if needed. + * + * @param {object} extraVars + * @returns {object} The formatted extra vars. + */ + formatExtraVars({ addon, ...extraVars }) { + if (addon) { + extraVars.addonId = addon.id; + extraVars.type = addon.type; + } + + // All the extra_vars in a telemetry event have to be strings. + for (var [key, value] of Object.entries(extraVars)) { + if (value == undefined) { + delete extraVars[key]; + } else { + extraVars[key] = this.convertToString(value); + } + } + + if (extraVars.addonId) { + extraVars.addonId = this.getTrimmedString(extraVars.addonId); + } + + return extraVars; + }, + + /** + * Record an install or update event for the given AddonInstall instance. + * + * @param {AddonInstall} install + * The AddonInstall instance to record an install or update event for. + * @param {object} extraVars + * The additional extra_vars to include in the recorded event. + * @param {string} extraVars.step + * The current step in the install or update flow. + * @param {string} extraVars.download_time + * The number of ms needed to download the extension. + * @param {string} extraVars.num_strings + * The number of permission description string for the extension + * permission doorhanger. + */ + recordInstallEvent(install, extraVars) { + // Early exit if AMTelemetry's telemetry setup has not been done yet. + if (!this.telemetrySetupDone) { + return; + } + + let extra = {}; + + let telemetryInfo = this.getInstallTelemetryInfo(install); + if (telemetryInfo && typeof telemetryInfo.source === "string") { + extra.source = telemetryInfo.source; + } + + if (extra.source === "internal") { + // Do not record the telemetry event for installation sources + // that are marked as "internal". + return; + } + + // Also include the install source's method when applicable (e.g. install events with + // source "about:addons" may have "install-from-file" or "url" as their source method). + if (telemetryInfo && typeof telemetryInfo.method === "string") { + extra.method = telemetryInfo.method; + } + + let addonId = this.getAddonIdFromInstall(install); + let object = this.getEventObjectFromInstall(install); + + let installId = String(install.installId); + let eventMethod = install.existingAddon ? "update" : "install"; + + if (addonId) { + extra.addon_id = this.getTrimmedString(addonId); + } + + if (install.error) { + extra.error = AddonManager.errorToString(install.error); + } + + if ( + eventMethod === "install" && + Services.prefs.getBoolPref("extensions.install_origins.enabled", true) + ) { + // This is converted to "1" / "0". + extra.install_origins = Array.isArray(install.addon?.installOrigins); + } + + if (eventMethod === "update") { + // For "update" telemetry events, also include an extra var which determine + // if the update has been requested by the user. + extra.updated_from = install.isUserRequestedUpdate ? "user" : "app"; + } + + // All the extra vars in a telemetry event have to be strings. + extra = this.formatExtraVars({ ...extraVars, ...extra }); + + this.recordEvent({ method: eventMethod, object, value: installId, extra }); + }, + + /** + * Record a manage event for the given addon. + * + * @param {AddonWrapper} addon + * The AddonWrapper instance. + * @param {object} extraVars + * The additional extra_vars to include in the recorded event. + * @param {string} extraVars.num_strings + * The number of permission description string for the extension + * permission doorhanger. + */ + recordManageEvent(addon, method, extraVars) { + // Early exit if AMTelemetry's telemetry setup has not been done yet. + if (!this.telemetrySetupDone) { + return; + } + + let extra = {}; + + if (addon.installTelemetryInfo) { + if ("source" in addon.installTelemetryInfo) { + extra.source = addon.installTelemetryInfo.source; + } + + // Also include the install source's method when applicable (e.g. install events with + // source "about:addons" may have "install-from-file" or "url" as their source method). + if ("method" in addon.installTelemetryInfo) { + extra.method = addon.installTelemetryInfo.method; + } + } + + if (extra.source === "internal") { + // Do not record the telemetry event for installation sources + // that are marked as "internal". + return; + } + + let object = this.getEventObjectFromAddonType(addon.type); + let value = this.getTrimmedString(addon.id); + + extra = { ...extraVars, ...extra }; + + let hasExtraVars = !!Object.keys(extra).length; + extra = this.formatExtraVars(extra); + + this.recordEvent({ + method, + object, + value, + extra: hasExtraVars ? extra : null, + }); + }, + + /** + * Record an event on abuse report submissions. + * + * @params {object} opts + * @params {string} opts.addonId + * The id of the addon being reported. + * @params {string} [opts.addonType] + * The type of the addon being reported (only present for an existing + * addonId). + * @params {string} [opts.errorType] + * The AbuseReport errorType for a submission failure. + * @params {string} opts.reportEntryPoint + * The entry point of the abuse report. + */ + recordReportEvent({ addonId, addonType, errorType, reportEntryPoint }) { + this.recordEvent({ + method: "report", + object: reportEntryPoint, + value: addonId, + extra: this.formatExtraVars({ + addon_type: addonType, + error_type: errorType, + }), + }); + }, + + recordEvent({ method, object, value, extra }) { + if (typeof value != "string") { + // The value must be a string or null, make sure it's valid so sending + // the event doesn't fail. + value = null; + } + try { + Services.telemetry.recordEvent( + "addonsManager", + method, + object, + value, + extra + ); + } catch (err) { + // If the telemetry throws just log the error so it doesn't break any + // functionality. + Cu.reportError(err); + } + }, +}; + +AddonManager.init(); + +// Setup the AMTelemetry once the AddonManager has been started. +AddonManager.addManagerListener(AMTelemetry); +Object.freeze(AddonManagerInternal); +Object.freeze(AddonManagerPrivate); +Object.freeze(AddonManager); |