summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions/internal
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/mozapps/extensions/internal
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/mozapps/extensions/internal')
-rw-r--r--toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs1257
-rw-r--r--toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs138
-rw-r--r--toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs1876
-rw-r--r--toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs643
-rw-r--r--toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs934
-rw-r--r--toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs601
-rw-r--r--toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs661
-rw-r--r--toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs3832
-rw-r--r--toolkit/mozapps/extensions/internal/XPIExports.sys.mjs36
-rw-r--r--toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs4897
-rw-r--r--toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs3377
-rw-r--r--toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs57
-rw-r--r--toolkit/mozapps/extensions/internal/moz.build29
-rw-r--r--toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs72
14 files changed, 18410 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs b/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs
new file mode 100644
index 0000000000..e854e04b3c
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs
@@ -0,0 +1,1257 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+ ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
+});
+
+// The current platform as specified in the AMO API:
+// http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#addon-detail-platform
+ChromeUtils.defineLazyGetter(lazy, "PLATFORM", () => {
+ let platform = Services.appinfo.OS;
+ switch (platform) {
+ case "Darwin":
+ return "mac";
+
+ case "Linux":
+ return "linux";
+
+ case "Android":
+ return "android";
+
+ case "WINNT":
+ return "windows";
+ }
+ return platform;
+});
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "getAddonsCacheEnabled",
+ PREF_GETADDONS_CACHE_ENABLED
+);
+
+const PREF_GETADDONS_CACHE_TYPES = "extensions.getAddons.cache.types";
+const PREF_GETADDONS_CACHE_ID_ENABLED =
+ "extensions.%ID%.getAddons.cache.enabled";
+const PREF_GETADDONS_BROWSEADDONS = "extensions.getAddons.browseAddons";
+const PREF_GETADDONS_BYIDS = "extensions.getAddons.get.url";
+const PREF_GETADDONS_BROWSESEARCHRESULTS =
+ "extensions.getAddons.search.browseURL";
+const PREF_GETADDONS_DB_SCHEMA = "extensions.getAddons.databaseSchema";
+const PREF_GET_LANGPACKS = "extensions.getAddons.langpacks.url";
+const PREF_GET_BROWSER_MAPPINGS = "extensions.getAddons.browserMappings.url";
+
+const PREF_METADATA_LASTUPDATE = "extensions.getAddons.cache.lastUpdate";
+const PREF_METADATA_UPDATETHRESHOLD_SEC =
+ "extensions.getAddons.cache.updateThreshold";
+const DEFAULT_METADATA_UPDATETHRESHOLD_SEC = 172800; // two days
+
+const DEFAULT_CACHE_TYPES = "extension,theme,locale,dictionary";
+
+const FILE_DATABASE = "addons.json";
+const DB_SCHEMA = 6;
+const DB_MIN_JSON_SCHEMA = 5;
+const DB_BATCH_TIMEOUT_MS = 50;
+
+const BLANK_DB = function () {
+ return {
+ addons: new Map(),
+ schema: DB_SCHEMA,
+ };
+};
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.repository";
+
+// Create a new logger for use by the Addons Repository
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+function convertHTMLToPlainText(html) {
+ if (!html) {
+ return html;
+ }
+ var converter = Cc[
+ "@mozilla.org/widget/htmlformatconverter;1"
+ ].createInstance(Ci.nsIFormatConverter);
+
+ var input = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ input.data = html.replace(/\n/g, "<br>");
+
+ var output = {};
+ converter.convert("text/html", input, "text/plain", output);
+
+ if (output.value instanceof Ci.nsISupportsString) {
+ return output.value.data.replace(/\r\n/g, "\n");
+ }
+ return html;
+}
+
+async function getAddonsToCache(aIds) {
+ let types = Services.prefs.getStringPref(
+ PREF_GETADDONS_CACHE_TYPES,
+ DEFAULT_CACHE_TYPES
+ );
+
+ types = types.split(",");
+
+ let addons = await lazy.AddonManager.getAddonsByIDs(aIds);
+ let enabledIds = [];
+
+ for (let [i, addon] of addons.entries()) {
+ var preference = PREF_GETADDONS_CACHE_ID_ENABLED.replace("%ID%", aIds[i]);
+ // If the preference doesn't exist caching is enabled by default
+ if (!Services.prefs.getBoolPref(preference, true)) {
+ continue;
+ }
+
+ // The add-ons manager may not know about this ID yet if it is a pending
+ // install. In that case we'll just cache it regardless
+
+ // Don't cache add-ons of the wrong types
+ if (addon && !types.includes(addon.type)) {
+ continue;
+ }
+
+ // Don't cache system add-ons
+ if (addon && addon.isSystem) {
+ continue;
+ }
+
+ enabledIds.push(aIds[i]);
+ }
+
+ return enabledIds;
+}
+
+function AddonSearchResult(aId) {
+ this.id = aId;
+ this.icons = {};
+ this._unsupportedProperties = {};
+}
+
+AddonSearchResult.prototype = {
+ /**
+ * The ID of the add-on
+ */
+ id: null,
+
+ /**
+ * The add-on type (e.g. "extension" or "theme")
+ */
+ type: null,
+
+ /**
+ * The name of the add-on
+ */
+ name: null,
+
+ /**
+ * The version of the add-on
+ */
+ version: null,
+
+ /**
+ * The creator of the add-on
+ */
+ creator: null,
+
+ /**
+ * The developers of the add-on
+ */
+ developers: null,
+
+ /**
+ * A short description of the add-on
+ */
+ description: null,
+
+ /**
+ * The full description of the add-on
+ */
+ fullDescription: null,
+
+ /**
+ * The end-user licensing agreement (EULA) of the add-on
+ */
+ eula: null,
+
+ /**
+ * The url of the add-on's icon
+ */
+ get iconURL() {
+ return this.icons && this.icons[32];
+ },
+
+ /**
+ * The URLs of the add-on's icons, as an object with icon size as key
+ */
+ icons: null,
+
+ /**
+ * An array of screenshot urls for the add-on
+ */
+ screenshots: null,
+
+ /**
+ * The homepage for the add-on
+ */
+ homepageURL: null,
+
+ /**
+ * The support URL for the add-on
+ */
+ supportURL: null,
+
+ /**
+ * The contribution url of the add-on
+ */
+ contributionURL: null,
+
+ /**
+ * The rating of the add-on, 0-5
+ */
+ averageRating: null,
+
+ /**
+ * The number of reviews for this add-on
+ */
+ reviewCount: null,
+
+ /**
+ * The URL to the list of reviews for this add-on
+ */
+ reviewURL: null,
+
+ /**
+ * The number of times the add-on was downloaded the current week
+ */
+ weeklyDownloads: null,
+
+ /**
+ * The URL to the AMO detail page of this (listed) add-on
+ */
+ amoListingURL: null,
+
+ /**
+ * AddonInstall object generated from the add-on XPI url
+ */
+ install: null,
+
+ /**
+ * nsIURI storing where this add-on was installed from
+ */
+ sourceURI: null,
+
+ /**
+ * The Date that the add-on was most recently updated
+ */
+ updateDate: null,
+
+ toJSON() {
+ let json = {};
+
+ for (let property of Object.keys(this)) {
+ let value = this[property];
+ if (property.startsWith("_") || typeof value === "function") {
+ continue;
+ }
+
+ try {
+ switch (property) {
+ case "sourceURI":
+ json.sourceURI = value ? value.spec : "";
+ break;
+
+ case "updateDate":
+ json.updateDate = value ? value.getTime() : "";
+ break;
+
+ default:
+ json[property] = value;
+ }
+ } catch (ex) {
+ logger.warn("Error writing property value for " + property);
+ }
+ }
+
+ for (let property of Object.keys(this._unsupportedProperties)) {
+ let value = this._unsupportedProperties[property];
+ if (!property.startsWith("_")) {
+ json[property] = value;
+ }
+ }
+
+ return json;
+ },
+};
+
+/**
+ * The add-on repository is a source of add-ons that can be installed. It can
+ * be searched in three ways. The first takes a list of IDs and returns a
+ * list of the corresponding add-ons. The second returns a list of add-ons that
+ * come highly recommended. This list should change frequently. The third is to
+ * search for specific search terms entered by the user. Searches are
+ * asynchronous and results should be passed to the provided callback object
+ * when complete. The results passed to the callback should only include add-ons
+ * that are compatible with the current application and are not already
+ * installed.
+ */
+export var AddonRepository = {
+ /**
+ * The homepage for visiting this repository. If the corresponding preference
+ * is not defined, defaults to about:blank.
+ */
+ get homepageURL() {
+ let url = this._formatURLPref(PREF_GETADDONS_BROWSEADDONS, {});
+ return url != null ? url : "about:blank";
+ },
+
+ get appIsShuttingDown() {
+ return Services.startup.shuttingDown;
+ },
+
+ /**
+ * Retrieves the url that can be visited to see search results for the given
+ * terms. If the corresponding preference is not defined, defaults to
+ * about:blank.
+ *
+ * @param aSearchTerms
+ * Search terms used to search the repository
+ */
+ getSearchURL(aSearchTerms) {
+ let url = this._formatURLPref(PREF_GETADDONS_BROWSESEARCHRESULTS, {
+ TERMS: aSearchTerms,
+ });
+ return url != null ? url : "about:blank";
+ },
+
+ /**
+ * Whether caching is currently enabled
+ */
+ get cacheEnabled() {
+ return lazy.getAddonsCacheEnabled;
+ },
+
+ /**
+ * Shut down AddonRepository
+ * return: promise{integer} resolves with the result of flushing
+ * the AddonRepository database
+ */
+ shutdown() {
+ return AddonDatabase.shutdown(false);
+ },
+
+ metadataAge() {
+ let now = Math.round(Date.now() / 1000);
+ let lastUpdate = Services.prefs.getIntPref(PREF_METADATA_LASTUPDATE, 0);
+ return Math.max(0, now - lastUpdate);
+ },
+
+ isMetadataStale() {
+ let threshold = Services.prefs.getIntPref(
+ PREF_METADATA_UPDATETHRESHOLD_SEC,
+ DEFAULT_METADATA_UPDATETHRESHOLD_SEC
+ );
+ return this.metadataAge() > threshold;
+ },
+
+ /**
+ * Asynchronously get a cached add-on by id. The add-on (or null if the
+ * add-on is not found) is passed to the specified callback. If caching is
+ * disabled, null is passed to the specified callback.
+ *
+ * The callback variant exists only for existing code in XPIProvider.sys.mjs
+ * and XPIDatabase.sys.mjs that requires a synchronous callback, yuck.
+ *
+ * @param aId
+ * The id of the add-on to get
+ */
+ async getCachedAddonByID(aId, aCallback) {
+ if (!aId || !this.cacheEnabled) {
+ if (aCallback) {
+ aCallback(null);
+ }
+ return null;
+ }
+
+ if (aCallback && AddonDatabase._loaded) {
+ let addon = AddonDatabase.getAddon(aId);
+ aCallback(addon);
+ return addon;
+ }
+
+ await AddonDatabase.openConnection();
+
+ let addon = AddonDatabase.getAddon(aId);
+ if (aCallback) {
+ aCallback(addon);
+ }
+ return addon;
+ },
+
+ /*
+ * Clear and delete the AddonRepository database
+ * @return Promise{null} resolves when the database is deleted
+ */
+ _clearCache() {
+ return AddonDatabase.delete().then(() =>
+ lazy.AddonManagerPrivate.updateAddonRepositoryData()
+ );
+ },
+
+ /*
+ * Create a ServiceRequest instance.
+ * @return ServiceRequest returns a ServiceRequest instance.
+ */
+ _createServiceRequest() {
+ return new lazy.ServiceRequest({ mozAnon: true });
+ },
+
+ /**
+ * Fetch data from an API where the results may span multiple "pages".
+ * This function will take care of issuing multiple requests until all
+ * the results have been fetched, and will coalesce them all into a
+ * single return value. The handling here is specific to the way AMO
+ * implements paging (ie a JSON result with a "next" property).
+ *
+ * @param {string} pref
+ * The pref name that contains the API URL to call.
+ * @param {object} params
+ * A key-value object that contains the parameters to replace
+ * in the API URL.
+ * @param {function} handler
+ * This function will be called once per page of results,
+ * it should return an array of objects (the type depends
+ * on the particular API being called of course).
+ *
+ * @returns Promise{array} An array of all the individual results from
+ * the API call(s).
+ */
+ _fetchPaged(pref, params, handler) {
+ const startURL = this._formatURLPref(pref, params);
+
+ let results = [];
+ const fetchNextPage = url => {
+ return new Promise((resolve, reject) => {
+ if (this.appIsShuttingDown) {
+ logger.debug(
+ "Rejecting AddonRepository._fetchPaged call, shutdown already in progress"
+ );
+ reject(
+ new Error(
+ `Reject ServiceRequest for "${url}", shutdown already in progress`
+ )
+ );
+ return;
+ }
+ let request = this._createServiceRequest();
+ request.mozBackgroundRequest = true;
+ request.open("GET", url, true);
+ request.responseType = "json";
+
+ request.addEventListener("error", aEvent => {
+ reject(new Error(`GET ${url} failed`));
+ });
+ request.addEventListener("timeout", aEvent => {
+ reject(new Error(`GET ${url} timed out`));
+ });
+ request.addEventListener("load", aEvent => {
+ let response = request.response;
+ if (!response || (request.status != 200 && request.status != 0)) {
+ reject(new Error(`GET ${url} failed (status ${request.status})`));
+ return;
+ }
+
+ try {
+ results.push(...handler(response.results));
+ } catch (err) {
+ reject(err);
+ }
+
+ if (response.next) {
+ resolve(fetchNextPage(response.next));
+ }
+
+ resolve(results);
+ });
+
+ request.send(null);
+ });
+ };
+
+ return fetchNextPage(startURL);
+ },
+
+ /**
+ * Fetch metadata for a given set of addons from AMO.
+ *
+ * @param aIDs
+ * The array of ids to retrieve metadata for.
+ * @returns {array<AddonSearchResult>}
+ */
+ async getAddonsByIDs(aIDs) {
+ const idCheck = aIDs.map(id => {
+ if (id.startsWith("rta:")) {
+ return atob(id.split(":")[1]);
+ }
+ return id;
+ });
+
+ const addons = await this._fetchPaged(
+ PREF_GETADDONS_BYIDS,
+ { IDS: aIDs.join(",") },
+ results =>
+ results
+ .map(entry => this._parseAddon(entry))
+ // Only return the add-ons corresponding the IDs passed to this method.
+ .filter(addon => idCheck.includes(addon.id))
+ );
+
+ return addons;
+ },
+
+ /**
+ * Fetch the Firefox add-ons mapped to the list of extension IDs for the
+ * browser ID passed to this method.
+ *
+ * See: https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#browser-mappings
+ *
+ * @param browserID
+ * The browser ID used to retrieve the mapping of IDs.
+ * @param extensionIDs
+ * The array of browser (non-Firefox) extension IDs to retrieve
+ * metadata for.
+ * @returns {object} result
+ * The result of the mapping.
+ * @returns {array<AddonSearchResult>} result.addons
+ * The AddonSearchResults for the addons that were successfully mapped.
+ * @returns {array<string>} result.matchedIDs
+ * The IDs of the extensions that were successfully matched to
+ * equivalents that can be installed in this browser. These are
+ * the IDs before matching to equivalents.
+ * @returns {array<string>} result.unmatchedIDs
+ * The IDs of the extensions that were not matched to equivalents.
+ */
+ async getMappedAddons(browserID, extensionIDs) {
+ let matchedExtensionIDs = new Set();
+ let unmatchedExtensionIDs = new Set(extensionIDs);
+
+ const addonIds = await this._fetchPaged(
+ PREF_GET_BROWSER_MAPPINGS,
+ { BROWSER: browserID },
+ results =>
+ results
+ // Filter out all the entries with an extension ID not in the list
+ // passed to the method.
+ .filter(entry => {
+ if (unmatchedExtensionIDs.has(entry.extension_id)) {
+ unmatchedExtensionIDs.delete(entry.extension_id);
+ matchedExtensionIDs.add(entry.extension_id);
+ return true;
+ }
+ return false;
+ })
+ // Return the add-on ID (stored as `guid` on AMO).
+ .map(entry => entry.addon_guid)
+ );
+
+ if (!addonIds.length) {
+ return {
+ addons: [],
+ matchedIDs: [],
+ unmatchedIDs: [...unmatchedExtensionIDs],
+ };
+ }
+
+ return {
+ addons: await this.getAddonsByIDs(addonIds),
+ matchedIDs: [...matchedExtensionIDs],
+ unmatchedIDs: [...unmatchedExtensionIDs],
+ };
+ },
+
+ /**
+ * Asynchronously add add-ons to the cache corresponding to the specified
+ * ids. If caching is disabled, the cache is unchanged.
+ *
+ * @param aIds
+ * The array of add-on ids to add to the cache
+ * @returns {array<AddonSearchResult>} Add-ons to add to the cache.
+ */
+ async cacheAddons(aIds) {
+ logger.debug(
+ "cacheAddons: enabled " + this.cacheEnabled + " IDs " + aIds.toSource()
+ );
+ if (!this.cacheEnabled) {
+ return [];
+ }
+
+ let ids = await getAddonsToCache(aIds);
+
+ // If there are no add-ons to cache, act as if caching is disabled
+ if (!ids.length) {
+ return [];
+ }
+
+ let addons = [];
+ try {
+ addons = await this.getAddonsByIDs(ids);
+ } catch (err) {
+ logger.error(`Error in addon metadata check: ${err.message}`);
+ }
+ if (addons.length) {
+ await AddonDatabase.update(addons);
+ }
+ return addons;
+ },
+
+ /**
+ * Get all installed addons from the AddonManager singleton.
+ *
+ * @return Promise{array<AddonWrapper>} Resolves to an array of AddonWrapper instances.
+ */
+ _getAllInstalledAddons() {
+ return lazy.AddonManager.getAllAddons();
+ },
+
+ /**
+ * Performs the periodic background update check.
+ *
+ * In Firefox Desktop builds, the background update check is triggered on a
+ * daily basis as part of the AOM background update check and registered
+ * from: `toolkit/mozapps/extensions/extensions.manifest`
+ *
+ * In GeckoView builds, add-ons are checked for updates individually. The
+ * `AddonRepository.backgroundUpdateCheck()` method is called by the
+ * `updateWebExtension()` method defined in `GeckoViewWebExtensions.sys.mjs`
+ * but only when `AddonRepository.isMetadataStale()` returns true.
+ *
+ * @return Promise{null} Resolves when the metadata update is complete.
+ */
+ async backgroundUpdateCheck() {
+ let shutter = (async () => {
+ if (this.appIsShuttingDown) {
+ logger.debug(
+ "Returning earlier from backgroundUpdateCheck, shutdown already in progress"
+ );
+ return;
+ }
+
+ let allAddons = await this._getAllInstalledAddons();
+
+ // Completely remove cache if caching is not enabled
+ if (!this.cacheEnabled) {
+ logger.debug("Clearing cache because it is disabled");
+ await this._clearCache();
+ return;
+ }
+
+ let ids = allAddons.map(a => a.id);
+ logger.debug("Repopulate add-on cache with " + ids.toSource());
+
+ let addonsToCache = await getAddonsToCache(ids);
+
+ // Completely remove cache if there are no add-ons to cache
+ if (!addonsToCache.length) {
+ logger.debug("Clearing cache because 0 add-ons were requested");
+ await this._clearCache();
+ return;
+ }
+
+ let addons;
+ try {
+ addons = await this.getAddonsByIDs(addonsToCache);
+ } catch (err) {
+ // This is likely to happen if the server is unreachable, e.g. when
+ // there is no network connectivity.
+ logger.error(`Error in addon metadata lookup: ${err.message}`);
+ // Return now to avoid calling repopulate with an empty array;
+ // doing so would clear the cache.
+ return;
+ }
+
+ AddonDatabase.repopulate(addons);
+
+ // Always call AddonManager updateAddonRepositoryData after we refill the cache
+ await lazy.AddonManagerPrivate.updateAddonRepositoryData();
+ })();
+ lazy.AddonManager.beforeShutdown.addBlocker(
+ "AddonRepository Background Updater",
+ shutter
+ );
+ await shutter;
+ lazy.AddonManager.beforeShutdown.removeBlocker(shutter);
+ },
+
+ /*
+ * Creates an AddonSearchResult by parsing an entry from the AMO API.
+ *
+ * @param aEntry
+ * An entry from the AMO search API to parse.
+ * @return Result object containing the parsed AddonSearchResult
+ */
+ _parseAddon(aEntry) {
+ let addon = new AddonSearchResult(aEntry.guid);
+
+ addon.name = aEntry.name;
+ if (typeof aEntry.current_version == "object") {
+ addon.version = String(aEntry.current_version.version);
+ if (Array.isArray(aEntry.current_version.files)) {
+ for (let file of aEntry.current_version.files) {
+ if (file.platform == "all" || file.platform == lazy.PLATFORM) {
+ if (file.url) {
+ addon.sourceURI = lazy.NetUtil.newURI(file.url);
+ }
+ break;
+ }
+ }
+ }
+ }
+ addon.homepageURL = aEntry.homepage;
+ addon.supportURL = aEntry.support_url;
+ addon.amoListingURL = aEntry.url;
+
+ addon.description = convertHTMLToPlainText(aEntry.summary);
+ addon.fullDescription = convertHTMLToPlainText(aEntry.description);
+
+ addon.weeklyDownloads = aEntry.weekly_downloads;
+
+ switch (aEntry.type) {
+ case "persona":
+ case "statictheme":
+ addon.type = "theme";
+ break;
+
+ case "language":
+ addon.type = "locale";
+ break;
+
+ default:
+ addon.type = aEntry.type;
+ break;
+ }
+
+ if (Array.isArray(aEntry.authors)) {
+ let authors = aEntry.authors.map(
+ author =>
+ new lazy.AddonManagerPrivate.AddonAuthor(author.name, author.url)
+ );
+ if (authors.length) {
+ addon.creator = authors[0];
+ addon.developers = authors.slice(1);
+ }
+ }
+
+ if (typeof aEntry.previews == "object") {
+ addon.screenshots = aEntry.previews.map(shot => {
+ let safeSize = orig =>
+ Array.isArray(orig) && orig.length >= 2 ? orig : [null, null];
+ let imageSize = safeSize(shot.image_size);
+ let thumbSize = safeSize(shot.thumbnail_size);
+ return new lazy.AddonManagerPrivate.AddonScreenshot(
+ shot.image_url,
+ imageSize[0],
+ imageSize[1],
+ shot.thumbnail_url,
+ thumbSize[0],
+ thumbSize[1],
+ shot.caption
+ );
+ });
+ }
+
+ addon.contributionURL = aEntry.contributions_url;
+
+ if (typeof aEntry.ratings == "object") {
+ addon.averageRating = Math.min(5, aEntry.ratings.average);
+ addon.reviewCount = aEntry.ratings.text_count;
+ }
+
+ addon.reviewURL = aEntry.ratings_url;
+ if (aEntry.last_updated) {
+ addon.updateDate = new Date(aEntry.last_updated);
+ }
+
+ addon.icons = aEntry.icons || {};
+
+ return addon;
+ },
+
+ // Create url from preference, returning null if preference does not exist
+ _formatURLPref(aPreference, aSubstitutions = {}) {
+ let url = Services.prefs.getCharPref(aPreference, "");
+ if (!url) {
+ logger.warn("_formatURLPref: Couldn't get pref: " + aPreference);
+ return null;
+ }
+
+ url = url.replace(/%([A-Z_]+)%/g, function (aMatch, aKey) {
+ return aKey in aSubstitutions
+ ? encodeURIComponent(aSubstitutions[aKey])
+ : aMatch;
+ });
+
+ return Services.urlFormatter.formatURL(url);
+ },
+
+ flush() {
+ return AddonDatabase.flush();
+ },
+
+ async getAvailableLangpacks() {
+ // This should be the API endpoint documented at:
+ // http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#language-tools
+ let url = this._formatURLPref(PREF_GET_LANGPACKS);
+
+ let response = await fetch(url, { credentials: "omit" });
+ if (!response.ok) {
+ throw new Error("fetching available language packs failed");
+ }
+
+ let data = await response.json();
+
+ let result = [];
+ for (let entry of data.results) {
+ if (
+ !entry.current_compatible_version ||
+ !entry.current_compatible_version.files
+ ) {
+ continue;
+ }
+
+ for (let file of entry.current_compatible_version.files) {
+ if (
+ file.platform == "all" ||
+ file.platform == Services.appinfo.OS.toLowerCase()
+ ) {
+ result.push({
+ target_locale: entry.target_locale,
+ url: file.url,
+ hash: file.hash,
+ });
+ }
+ }
+ }
+
+ return result;
+ },
+};
+
+var AddonDatabase = {
+ connectionPromise: null,
+ _loaded: false,
+ _saveTask: null,
+ _blockerAdded: false,
+
+ // the in-memory database
+ DB: BLANK_DB(),
+
+ /**
+ * A getter to retrieve the path to the DB
+ */
+ get jsonFile() {
+ return PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ FILE_DATABASE
+ );
+ },
+
+ /**
+ * Asynchronously opens a new connection to the database file.
+ *
+ * @return {Promise} a promise that resolves to the database.
+ */
+ openConnection() {
+ if (!this.connectionPromise) {
+ this.connectionPromise = (async () => {
+ let inputDB, schema;
+
+ try {
+ let data = await IOUtils.readUTF8(this.jsonFile);
+ inputDB = JSON.parse(data);
+
+ if (
+ !inputDB.hasOwnProperty("addons") ||
+ !Array.isArray(inputDB.addons)
+ ) {
+ throw new Error("No addons array.");
+ }
+
+ if (!inputDB.hasOwnProperty("schema")) {
+ throw new Error("No schema specified.");
+ }
+
+ schema = parseInt(inputDB.schema, 10);
+
+ if (!Number.isInteger(schema) || schema < DB_MIN_JSON_SCHEMA) {
+ throw new Error("Invalid schema value.");
+ }
+ } catch (e) {
+ if (e.name == "NotFoundError") {
+ logger.debug("No " + FILE_DATABASE + " found.");
+ } else {
+ logger.error(
+ `Malformed ${FILE_DATABASE}: ${e} - resetting to empty`
+ );
+ }
+
+ // Create a blank addons.json file
+ this.save();
+
+ Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA);
+ this._loaded = true;
+ return this.DB;
+ }
+
+ Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA);
+
+ // Convert the addon objects as necessary
+ // and store them in our in-memory copy of the database.
+ for (let addon of inputDB.addons) {
+ let id = addon.id;
+
+ let entry = this._parseAddon(addon);
+ this.DB.addons.set(id, entry);
+ }
+
+ this._loaded = true;
+ return this.DB;
+ })();
+ }
+
+ return this.connectionPromise;
+ },
+
+ /**
+ * Asynchronously shuts down the database connection and releases all
+ * cached objects
+ *
+ * @param aCallback
+ * An optional callback to call once complete
+ * @param aSkipFlush
+ * An optional boolean to skip flushing data to disk. Useful
+ * when the database is going to be deleted afterwards.
+ */
+ shutdown(aSkipFlush) {
+ if (!this.connectionPromise) {
+ return Promise.resolve();
+ }
+
+ this.connectionPromise = null;
+ this._loaded = false;
+
+ if (aSkipFlush) {
+ return Promise.resolve();
+ }
+
+ return this.flush();
+ },
+
+ /**
+ * Asynchronously deletes the database, shutting down the connection
+ * first if initialized
+ *
+ * @param aCallback
+ * An optional callback to call once complete
+ * @return Promise{null} resolves when the database has been deleted
+ */
+ delete(aCallback) {
+ this.DB = BLANK_DB();
+
+ if (this._saveTask) {
+ this._saveTask.disarm();
+ this._saveTask = null;
+ }
+
+ // shutdown(true) never rejects
+ this._deleting = this.shutdown(true)
+ .then(() => IOUtils.remove(this.jsonFile))
+ .catch(error =>
+ logger.error(
+ "Unable to delete Addon Repository file " + this.jsonFile,
+ error
+ )
+ )
+ .then(() => (this._deleting = null))
+ .then(aCallback);
+
+ return this._deleting;
+ },
+
+ async _saveNow() {
+ let json = {
+ schema: this.DB.schema,
+ addons: Array.from(this.DB.addons.values()),
+ };
+
+ await IOUtils.writeUTF8(this.jsonFile, JSON.stringify(json), {
+ tmpPath: `${this.jsonFile}.tmp`,
+ });
+ },
+
+ save() {
+ if (!this._saveTask) {
+ this._saveTask = new lazy.DeferredTask(
+ () => this._saveNow(),
+ DB_BATCH_TIMEOUT_MS
+ );
+
+ if (!this._blockerAdded) {
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "Flush AddonRepository",
+ () => this.flush()
+ );
+ this._blockerAdded = true;
+ }
+ }
+ this._saveTask.arm();
+ },
+
+ /**
+ * Flush any pending I/O on the addons.json file
+ * @return: Promise{null}
+ * Resolves when the pending I/O (writing out or deleting
+ * addons.json) completes
+ */
+ flush() {
+ if (this._deleting) {
+ return this._deleting;
+ }
+
+ if (this._saveTask) {
+ let promise = this._saveTask.finalize();
+ this._saveTask = null;
+ return promise;
+ }
+
+ return Promise.resolve();
+ },
+
+ /**
+ * Get an individual addon entry from the in-memory cache.
+ * Note: calling this function before the database is read will
+ * return undefined.
+ *
+ * @param {string} aId The id of the addon to retrieve.
+ */
+ getAddon(aId) {
+ return this.DB.addons.get(aId);
+ },
+
+ /**
+ * Asynchronously repopulates the database so it only contains the
+ * specified add-ons
+ *
+ * @param {array<AddonSearchResult>} aAddons
+ * Add-ons to repopulate the database with.
+ */
+ repopulate(aAddons) {
+ this.DB = BLANK_DB();
+ this._update(aAddons);
+
+ let now = Math.round(Date.now() / 1000);
+ logger.debug(
+ "Cache repopulated, setting " + PREF_METADATA_LASTUPDATE + " to " + now
+ );
+ Services.prefs.setIntPref(PREF_METADATA_LASTUPDATE, now);
+ },
+
+ /**
+ * Asynchronously insert new addons into the database.
+ *
+ * @param {array<AddonSearchResult>} aAddons
+ * Add-ons to insert/update in the database
+ */
+ async update(aAddons) {
+ await this.openConnection();
+
+ this._update(aAddons);
+ },
+
+ /**
+ * Merge the given addons into the database.
+ *
+ * @param {array<AddonSearchResult>} aAddons
+ * Add-ons to insert/update in the database
+ */
+ _update(aAddons) {
+ for (let addon of aAddons) {
+ this.DB.addons.set(addon.id, this._parseAddon(addon));
+ }
+
+ this.save();
+ },
+
+ /*
+ * Creates an AddonSearchResult by parsing an object structure
+ * retrieved from the DB JSON representation.
+ *
+ * @param aObj
+ * The object to parse
+ * @return Returns an AddonSearchResult object.
+ */
+ _parseAddon(aObj) {
+ if (aObj instanceof AddonSearchResult) {
+ return aObj;
+ }
+
+ let id = aObj.id;
+ if (!aObj.id) {
+ return null;
+ }
+
+ let addon = new AddonSearchResult(id);
+
+ for (let expectedProperty of Object.keys(AddonSearchResult.prototype)) {
+ if (
+ !(expectedProperty in aObj) ||
+ typeof aObj[expectedProperty] === "function"
+ ) {
+ continue;
+ }
+
+ let value = aObj[expectedProperty];
+
+ try {
+ switch (expectedProperty) {
+ case "sourceURI":
+ addon.sourceURI = value ? lazy.NetUtil.newURI(value) : null;
+ break;
+
+ case "creator":
+ addon.creator = value ? this._makeDeveloper(value) : null;
+ break;
+
+ case "updateDate":
+ addon.updateDate = value ? new Date(value) : null;
+ break;
+
+ case "developers":
+ if (!addon.developers) {
+ addon.developers = [];
+ }
+ for (let developer of value) {
+ addon.developers.push(this._makeDeveloper(developer));
+ }
+ break;
+
+ case "screenshots":
+ if (!addon.screenshots) {
+ addon.screenshots = [];
+ }
+ for (let screenshot of value) {
+ addon.screenshots.push(this._makeScreenshot(screenshot));
+ }
+ break;
+
+ case "icons":
+ if (!addon.icons) {
+ addon.icons = {};
+ }
+ for (let size of Object.keys(aObj.icons)) {
+ addon.icons[size] = aObj.icons[size];
+ }
+ break;
+
+ case "iconURL":
+ break;
+
+ default:
+ addon[expectedProperty] = value;
+ }
+ } catch (ex) {
+ logger.warn(
+ "Error in parsing property value for " + expectedProperty + " | " + ex
+ );
+ }
+
+ // delete property from obj to indicate we've already
+ // handled it. The remaining public properties will
+ // be stored separately and just passed through to
+ // be written back to the DB.
+ delete aObj[expectedProperty];
+ }
+
+ // Copy remaining properties to a separate object
+ // to prevent accidental access on downgraded versions.
+ // The properties will be merged in the same object
+ // prior to being written back through toJSON.
+ for (let remainingProperty of Object.keys(aObj)) {
+ switch (typeof aObj[remainingProperty]) {
+ case "boolean":
+ case "number":
+ case "string":
+ case "object":
+ // these types are accepted
+ break;
+ default:
+ continue;
+ }
+
+ if (!remainingProperty.startsWith("_")) {
+ addon._unsupportedProperties[remainingProperty] =
+ aObj[remainingProperty];
+ }
+ }
+
+ return addon;
+ },
+
+ /**
+ * Make a developer object from a vanilla
+ * JS object from the JSON database
+ *
+ * @param aObj
+ * The JS object to use
+ * @return The created developer
+ */
+ _makeDeveloper(aObj) {
+ let name = aObj.name;
+ let url = aObj.url;
+ return new lazy.AddonManagerPrivate.AddonAuthor(name, url);
+ },
+
+ /**
+ * Make a screenshot object from a vanilla
+ * JS object from the JSON database
+ *
+ * @param aObj
+ * The JS object to use
+ * @return The created screenshot
+ */
+ _makeScreenshot(aObj) {
+ let url = aObj.url;
+ let width = aObj.width;
+ let height = aObj.height;
+ let thumbnailURL = aObj.thumbnailURL;
+ let thumbnailWidth = aObj.thumbnailWidth;
+ let thumbnailHeight = aObj.thumbnailHeight;
+ let caption = aObj.caption;
+ return new lazy.AddonManagerPrivate.AddonScreenshot(
+ url,
+ width,
+ height,
+ thumbnailURL,
+ thumbnailWidth,
+ thumbnailHeight,
+ caption
+ );
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs b/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs
new file mode 100644
index 0000000000..09bb0adc97
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs
@@ -0,0 +1,138 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { AddonManager } from "resource://gre/modules/AddonManager.sys.mjs";
+
+const PREF_SIGNATURES_REQUIRED = "xpinstall.signatures.required";
+const PREF_LANGPACK_SIGNATURES = "extensions.langpacks.signatures.required";
+const PREF_ALLOW_EXPERIMENTS = "extensions.experiments.enabled";
+const PREF_EM_SIDELOAD_SCOPES = "extensions.sideloadScopes";
+const PREF_IS_EMBEDDED = "extensions.isembedded";
+const PREF_UPDATE_REQUIREBUILTINCERTS = "extensions.update.requireBuiltInCerts";
+const PREF_INSTALL_REQUIREBUILTINCERTS =
+ "extensions.install.requireBuiltInCerts";
+
+export var AddonSettings = {};
+
+// Make a non-changable property that can't be manipulated from other
+// code in the app.
+function makeConstant(name, value) {
+ Object.defineProperty(AddonSettings, name, {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value,
+ });
+}
+
+if (AppConstants.MOZ_REQUIRE_SIGNING && !Cu.isInAutomation) {
+ makeConstant("REQUIRE_SIGNING", true);
+ makeConstant("LANGPACKS_REQUIRE_SIGNING", true);
+} else {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "REQUIRE_SIGNING",
+ PREF_SIGNATURES_REQUIRED,
+ false
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "LANGPACKS_REQUIRE_SIGNING",
+ PREF_LANGPACK_SIGNATURES,
+ false
+ );
+}
+
+/**
+ * Require the use of certs shipped with Firefox for
+ * addon install and update, if the distribution does
+ * not require addon signing and is not ESR.
+ */
+XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "INSTALL_REQUIREBUILTINCERTS",
+ PREF_INSTALL_REQUIREBUILTINCERTS,
+ !AppConstants.MOZ_REQUIRE_SIGNING &&
+ !AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr")
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "UPDATE_REQUIREBUILTINCERTS",
+ PREF_UPDATE_REQUIREBUILTINCERTS,
+ !AppConstants.MOZ_REQUIRE_SIGNING &&
+ !AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr")
+);
+
+// Whether or not we're running in GeckoView embedded in an Android app
+if (Cu.isInAutomation) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "IS_EMBEDDED",
+ PREF_IS_EMBEDDED,
+ false
+ );
+} else {
+ makeConstant("IS_EMBEDDED", AppConstants.platform === "android");
+}
+
+/**
+ * AddonSettings.EXPERIMENTS_ENABLED
+ *
+ * Experimental APIs are always available to privileged signed addons.
+ * This constant makes an optional preference available to enable experimental
+ * APIs for developement purposes.
+ *
+ * Two features are toggled with this preference:
+ *
+ * 1. The ability to load an extension that contains an experimental
+ * API but is not privileged.
+ * 2. The ability to load an unsigned extension that gains privilege
+ * if it is temporarily loaded (e.g. via about:debugging).
+ *
+ * MOZ_REQUIRE_SIGNING is set to zero in unbranded builds, we also
+ * ensure nightly, dev-ed and our test infrastructure have access to
+ * the preference.
+ *
+ * Official releases ignore this preference.
+ */
+if (
+ !AppConstants.MOZ_REQUIRE_SIGNING ||
+ AppConstants.NIGHTLY_BUILD ||
+ AppConstants.MOZ_DEV_EDITION ||
+ Cu.isInAutomation
+) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "EXPERIMENTS_ENABLED",
+ PREF_ALLOW_EXPERIMENTS,
+ true
+ );
+} else {
+ makeConstant("EXPERIMENTS_ENABLED", false);
+}
+
+if (AppConstants.MOZ_DEV_EDITION) {
+ makeConstant("DEFAULT_THEME_ID", "firefox-compact-dark@mozilla.org");
+} else {
+ makeConstant("DEFAULT_THEME_ID", "default-theme@mozilla.org");
+}
+
+// SCOPES_SIDELOAD is a bitflag for what scopes we will load new extensions from when we scan the directories.
+// If a build allows sideloading, or we're in automation, we'll also allow use of the preference.
+if (AppConstants.MOZ_ALLOW_ADDON_SIDELOAD || Cu.isInAutomation) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "SCOPES_SIDELOAD",
+ PREF_EM_SIDELOAD_SCOPES,
+ AppConstants.MOZ_ALLOW_ADDON_SIDELOAD
+ ? AddonManager.SCOPE_ALL
+ : AddonManager.SCOPE_PROFILE
+ );
+} else {
+ makeConstant("SCOPES_SIDELOAD", AddonManager.SCOPE_PROFILE);
+}
diff --git a/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs b/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs
new file mode 100644
index 0000000000..7b30daa0e2
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs
@@ -0,0 +1,1876 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* eslint "mozilla/no-aArgs": 1 */
+/* eslint "no-unused-vars": [2, {"args": "none", "varsIgnorePattern": "^(Cc|Ci|Cr|Cu|EXPORTED_SYMBOLS)$"}] */
+/* eslint "semi": [2, "always"] */
+/* eslint "valid-jsdoc": [2, {requireReturn: false}] */
+
+const CERTDB_CONTRACTID = "@mozilla.org/security/x509certdb;1";
+
+import {
+ AddonManager,
+ AddonManagerPrivate,
+} from "resource://gre/modules/AddonManager.sys.mjs";
+import { AsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs";
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionAddonObserver: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs",
+ FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs",
+ Management: "resource://gre/modules/Extension.sys.mjs",
+ MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
+
+ XPCShellContentUtils:
+ "resource://testing-common/XPCShellContentUtils.sys.mjs",
+
+ getAppInfo: "resource://testing-common/AppInfo.sys.mjs",
+ updateAppInfo: "resource://testing-common/AppInfo.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ aomStartup: [
+ "@mozilla.org/addons/addon-manager-startup;1",
+ "amIAddonManagerStartup",
+ ],
+});
+
+const ArrayBufferInputStream = Components.Constructor(
+ "@mozilla.org/io/arraybuffer-input-stream;1",
+ "nsIArrayBufferInputStream",
+ "setData"
+);
+
+const nsFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+
+const ZipReader = Components.Constructor(
+ "@mozilla.org/libjar/zip-reader;1",
+ "nsIZipReader",
+ "open"
+);
+
+const ZipWriter = Components.Constructor(
+ "@mozilla.org/zipwriter;1",
+ "nsIZipWriter",
+ "open"
+);
+
+function isRegExp(val) {
+ return val && typeof val === "object" && typeof val.test === "function";
+}
+
+class MockBarrier {
+ constructor(name) {
+ this.name = name;
+ this.blockers = [];
+ }
+
+ addBlocker(name, blocker, options) {
+ this.blockers.push({ name, blocker, options });
+ }
+
+ async trigger() {
+ await Promise.all(
+ this.blockers.map(async ({ blocker, name }) => {
+ try {
+ if (typeof blocker == "function") {
+ await blocker();
+ } else {
+ await blocker;
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ dump(
+ `Shutdown blocker '${name}' for ${this.name} threw error: ${e} :: ${e.stack}\n`
+ );
+ }
+ })
+ );
+
+ this.blockers = [];
+ }
+}
+
+// Mock out AddonManager's reference to the AsyncShutdown module so we can shut
+// down AddonManager from the test
+export var MockAsyncShutdown = {
+ profileBeforeChange: new MockBarrier("profileBeforeChange"),
+ profileChangeTeardown: new MockBarrier("profileChangeTeardown"),
+ quitApplicationGranted: new MockBarrier("quitApplicationGranted"),
+ // We can use the real Barrier
+ Barrier: AsyncShutdown.Barrier,
+};
+
+AddonManagerPrivate.overrideAsyncShutdown(MockAsyncShutdown);
+
+class AddonsList {
+ constructor(file) {
+ this.extensions = [];
+ this.themes = [];
+ this.xpis = [];
+
+ if (!file.exists()) {
+ return;
+ }
+
+ let data = lazy.aomStartup.readStartupData();
+
+ for (let loc of Object.values(data)) {
+ let dir = loc.path && new nsFile(loc.path);
+
+ for (let addon of Object.values(loc.addons)) {
+ let file;
+ if (dir) {
+ file = dir.clone();
+ try {
+ file.appendRelativePath(addon.path);
+ } catch (e) {
+ file = new nsFile(addon.path);
+ }
+ } else if (addon.path) {
+ file = new nsFile(addon.path);
+ }
+
+ if (!file) {
+ continue;
+ }
+
+ this.xpis.push(file);
+
+ if (addon.enabled) {
+ addon.type = addon.type || "extension";
+
+ if (addon.type == "theme") {
+ this.themes.push(file);
+ } else {
+ this.extensions.push(file);
+ }
+ }
+ }
+ }
+ }
+
+ hasItem(type, dir, id) {
+ var path = dir.clone();
+ path.append(id);
+
+ var xpiPath = dir.clone();
+ xpiPath.append(`${id}.xpi`);
+
+ return this[type].some(file => {
+ if (!file.exists()) {
+ throw new Error(
+ `Non-existent path found in addonStartup.json: ${file.path}`
+ );
+ }
+
+ if (file.isDirectory()) {
+ return file.equals(path);
+ }
+ if (file.isFile()) {
+ return file.equals(xpiPath);
+ }
+ return false;
+ });
+ }
+
+ hasTheme(dir, id) {
+ return this.hasItem("themes", dir, id);
+ }
+
+ hasExtension(dir, id) {
+ return this.hasItem("extensions", dir, id);
+ }
+}
+
+// The number of resetXPIExports calls.
+//
+// This is added to the URL of the modules once resetXPIExports is called,
+// so that they become different module instances for each reset, and also the
+// suffix is not used outside of tests.
+let resetXPIExportsCount = 0;
+
+// Reset all properties of XPIExports to lazy getters, with new module URIs,
+// in order to simulate the shutdown+restart situation.
+function resetXPIExports(XPIExports) {
+ resetXPIExportsCount++;
+
+ const suffix = "?" + resetXPIExportsCount;
+
+ // The list of lazy getters should be in sync with XPIExports.sys.mjs.
+ //
+ // eslint-disable-next-line mozilla/lazy-getter-object-name
+ ChromeUtils.defineESModuleGetters(XPIExports, {
+ // XPIDatabase.sys.mjs
+ AddonInternal: "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
+ BuiltInThemesHelpers:
+ "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
+ XPIDatabase: "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
+ XPIDatabaseReconcile:
+ "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
+
+ // XPIInstall.sys.mjs
+ UpdateChecker: "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,
+ XPIInstall: "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,
+ verifyBundleSignedState:
+ "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,
+
+ // XPIProvider.sys.mjs
+ XPIProvider: "resource://gre/modules/addons/XPIProvider.sys.mjs" + suffix,
+ XPIInternal: "resource://gre/modules/addons/XPIProvider.sys.mjs" + suffix,
+ });
+}
+
+export var AddonTestUtils = {
+ addonIntegrationService: null,
+ addonsList: null,
+ appInfo: null,
+ addonStartup: null,
+ collectedTelemetryEvents: [],
+ testScope: null,
+ testUnpacked: false,
+ useRealCertChecks: false,
+ usePrivilegedSignatures: true,
+ certSignatureDate: null,
+ overrideEntry: null,
+
+ maybeInit(testScope) {
+ if (this.testScope != testScope) {
+ this.init(testScope);
+ }
+ },
+
+ init(testScope, enableLogging = true) {
+ if (this.testScope === testScope) {
+ return;
+ }
+ this.testScope = testScope;
+
+ // Get the profile directory for tests to use.
+ this.profileDir = testScope.do_get_profile();
+
+ this.profileExtensions = this.profileDir.clone();
+ this.profileExtensions.append("extensions");
+
+ this.addonStartup = this.profileDir.clone();
+ this.addonStartup.append("addonStartup.json.lz4");
+
+ // Register a temporary directory for the tests.
+ this.tempDir = this.profileDir.clone();
+ this.tempDir.append("temp");
+ this.tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ this.registerDirectory("TmpD", this.tempDir);
+
+ // Create a replacement app directory for the tests.
+ const appDirForAddons = this.profileDir.clone();
+ appDirForAddons.append("appdir-addons");
+ appDirForAddons.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ this.registerDirectory("XREAddonAppDir", appDirForAddons);
+
+ // Enable more extensive EM logging.
+ if (enableLogging) {
+ Services.prefs.setBoolPref("extensions.logging.enabled", true);
+ }
+
+ // By default only load extensions from the profile install location
+ Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE
+ );
+
+ // By default don't disable add-ons from any scope
+ Services.prefs.setIntPref("extensions.autoDisableScopes", 0);
+
+ // And scan for changes at startup
+ Services.prefs.setIntPref("extensions.startupScanScopes", 15);
+
+ // By default, don't cache add-ons in AddonRepository.jsm
+ Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", false);
+
+ // Point update checks to the local machine for fast failures
+ Services.prefs.setCharPref(
+ "extensions.update.url",
+ "http://127.0.0.1/updateURL"
+ );
+ Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ "http://127.0.0.1/updateBackgroundURL"
+ );
+ Services.prefs.setCharPref(
+ "services.settings.server",
+ "data:,#remote-settings-dummy/v1"
+ );
+
+ // By default ignore bundled add-ons
+ Services.prefs.setBoolPref("extensions.installDistroAddons", false);
+
+ // Ensure signature checks are enabled by default
+ Services.prefs.setBoolPref("xpinstall.signatures.required", true);
+
+ // Make sure that a given path does not exist
+ function pathShouldntExist(file) {
+ if (file.exists()) {
+ throw new Error(
+ `Test cleanup: path ${file.path} exists when it should not`
+ );
+ }
+ }
+
+ testScope.registerCleanupFunction(() => {
+ // Force a GC to ensure that anything holding a ref to temp file releases it.
+ // XXX This shouldn't be needed here, since cleanupTempXPIs() does a GC if
+ // something fails; see bug 1761255
+ this.info(`Force a GC`);
+ Cu.forceGC();
+
+ this.cleanupTempXPIs();
+
+ let ignoreEntries = new Set();
+ {
+ // FileTestUtils lazily creates a directory to hold the temporary files
+ // it creates. If that directory exists, ignore it.
+ let { value } = Object.getOwnPropertyDescriptor(
+ lazy.FileTestUtils,
+ "_globalTemporaryDirectory"
+ );
+ if (value) {
+ ignoreEntries.add(value.leafName);
+ }
+ }
+
+ // Check that the temporary directory is empty
+ var entries = [];
+ for (let { leafName } of this.iterDirectory(this.tempDir)) {
+ if (!ignoreEntries.has(leafName)) {
+ entries.push(leafName);
+ }
+ }
+ if (entries.length) {
+ throw new Error(
+ `Found unexpected files in temporary directory: ${entries.join(", ")}`
+ );
+ }
+
+ try {
+ appDirForAddons.remove(true);
+ } catch (ex) {
+ testScope.info(`Got exception removing addon app dir: ${ex}`);
+ }
+
+ // ensure no leftover files in the system addon upgrade location
+ let featuresDir = this.profileDir.clone();
+ featuresDir.append("features");
+ // upgrade directories will be in UUID folders under features/
+ for (let dir of this.iterDirectory(featuresDir)) {
+ dir.append("stage");
+ pathShouldntExist(dir);
+ }
+
+ // ensure no leftover files in the user addon location
+ let testDir = this.profileDir.clone();
+ testDir.append("extensions");
+ testDir.append("trash");
+ pathShouldntExist(testDir);
+
+ testDir.leafName = "staged";
+ pathShouldntExist(testDir);
+
+ return this.promiseShutdownManager();
+ });
+ },
+
+ initMochitest(testScope) {
+ if (this.testScope === testScope) {
+ return;
+ }
+ this.testScope = testScope;
+
+ this.profileDir = FileUtils.getDir("ProfD", []);
+
+ this.profileExtensions = FileUtils.getDir("ProfD", ["extensions"]);
+
+ this.tempDir = FileUtils.getDir("TmpD", []);
+ this.tempDir.append("addons-mochitest");
+ this.tempDir.createUnique(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+
+ testScope.registerCleanupFunction(() => {
+ // Defer testScope cleanup until the last cleanup function has run.
+ testScope.registerCleanupFunction(() => {
+ this.testScope = null;
+ });
+ this.cleanupTempXPIs();
+ try {
+ this.tempDir.remove(true);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ });
+ },
+
+ /**
+ * Iterates over the entries in a given directory.
+ *
+ * Fails silently if the given directory does not exist.
+ *
+ * @param {nsIFile} dir
+ * Directory to iterate.
+ */
+ *iterDirectory(dir) {
+ let dirEnum;
+ try {
+ dirEnum = dir.directoryEntries;
+ let file;
+ while ((file = dirEnum.nextFile)) {
+ yield file;
+ }
+ } catch (e) {
+ if (dir.exists()) {
+ Cu.reportError(e);
+ }
+ } finally {
+ if (dirEnum) {
+ dirEnum.close();
+ }
+ }
+ },
+
+ /**
+ * Creates a new HttpServer for testing, and begins listening on the
+ * specified port. Automatically shuts down the server when the test
+ * unit ends.
+ *
+ * @param {object} [options = {}]
+ * The options object.
+ * @param {integer} [options.port = -1]
+ * The port to listen on. If omitted, listen on a random
+ * port. The latter is the preferred behavior.
+ * @param {sequence<string>?} [options.hosts = null]
+ * A set of hosts to accept connections to. Support for this is
+ * implemented using a proxy filter.
+ *
+ * @returns {HttpServer}
+ * The HTTP server instance.
+ */
+ createHttpServer(...args) {
+ lazy.XPCShellContentUtils.ensureInitialized(this.testScope);
+ return lazy.XPCShellContentUtils.createHttpServer(...args);
+ },
+
+ registerJSON(...args) {
+ return lazy.XPCShellContentUtils.registerJSON(...args);
+ },
+
+ info(msg) {
+ // info() for mochitests, do_print for xpcshell.
+ let print = this.testScope.info || this.testScope.do_print;
+ print(msg);
+ },
+
+ cleanupTempXPIs() {
+ let didGC = false;
+
+ for (let file of this.tempXPIs.splice(0)) {
+ if (file.exists()) {
+ try {
+ Services.obs.notifyObservers(file, "flush-cache-entry");
+ file.remove(false);
+ } catch (e) {
+ if (didGC) {
+ Cu.reportError(`Failed to remove ${file.path}: ${e}`);
+ } else {
+ // Bug 1606684 - Sometimes XPI files are still in use by a process
+ // after the test has been finished. Force a GC once and try again.
+ this.info(`Force a GC`);
+ Cu.forceGC();
+ didGC = true;
+
+ try {
+ file.remove(false);
+ } catch (e) {
+ Cu.reportError(`Failed to remove ${file.path} after GC: ${e}`);
+ }
+ }
+ }
+ }
+ }
+ },
+
+ createAppInfo(ID, name, version, platformVersion = "1.0") {
+ lazy.updateAppInfo({
+ ID,
+ name,
+ version,
+ platformVersion,
+ crashReporter: true,
+ });
+ this.appInfo = lazy.getAppInfo();
+ },
+
+ getManifestURI(file) {
+ if (file.isDirectory()) {
+ file.leafName = "manifest.json";
+ if (file.exists()) {
+ return NetUtil.newURI(file);
+ }
+
+ throw new Error("No manifest file present");
+ }
+
+ let zip = ZipReader(file);
+ try {
+ let uri = NetUtil.newURI(file);
+
+ if (zip.hasEntry("manifest.json")) {
+ return NetUtil.newURI(`jar:${uri.spec}!/manifest.json`);
+ }
+
+ throw new Error("No manifest file present");
+ } finally {
+ zip.close();
+ }
+ },
+
+ getIDFromExtension(file) {
+ return this.getIDFromManifest(this.getManifestURI(file));
+ },
+
+ async getIDFromManifest(manifestURI) {
+ let body = await fetch(manifestURI.spec);
+ let manifest = await body.json();
+ try {
+ if (manifest.browser_specific_settings?.gecko?.id) {
+ return manifest.browser_specific_settings.gecko.id;
+ }
+ return manifest.applications.gecko.id;
+ } catch (e) {
+ // IDs for WebExtensions are extracted from the certificate when
+ // not present in the manifest, so just generate a random one.
+ return Services.uuid.generateUUID().number;
+ }
+ },
+
+ overrideCertDB() {
+ let verifyCert = async (file, result, cert, callback) => {
+ if (
+ result == Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED &&
+ !this.useRealCertChecks &&
+ callback.wrappedJSObject
+ ) {
+ // Bypassing XPConnect allows us to create a fake x509 certificate from JS
+ callback = callback.wrappedJSObject;
+
+ try {
+ let id;
+ try {
+ let manifestURI = this.getManifestURI(file);
+ id = await this.getIDFromManifest(manifestURI);
+ } catch (err) {
+ if (file.leafName.endsWith(".xpi")) {
+ id = file.leafName.slice(0, -4);
+ }
+ }
+
+ let fakeCert = { commonName: id };
+ if (this.usePrivilegedSignatures) {
+ let privileged =
+ typeof this.usePrivilegedSignatures == "function"
+ ? this.usePrivilegedSignatures(id)
+ : this.usePrivilegedSignatures;
+ if (privileged === "system") {
+ fakeCert.organizationalUnit = "Mozilla Components";
+ } else if (privileged) {
+ fakeCert.organizationalUnit = "Mozilla Extensions";
+ }
+ }
+ if (this.certSignatureDate) {
+ // addon.signedDate is derived from this, used by the blocklist.
+ fakeCert.validity = {
+ notBefore: this.certSignatureDate * 1000,
+ };
+ }
+
+ return [callback, Cr.NS_OK, fakeCert];
+ } catch (e) {
+ // If there is any error then just pass along the original results
+ } finally {
+ // Make sure to close the open zip file or it will be locked.
+ if (file.isFile()) {
+ Services.obs.notifyObservers(
+ file,
+ "flush-cache-entry",
+ "cert-override"
+ );
+ }
+ }
+ }
+
+ return [callback, result, cert];
+ };
+
+ let FakeCertDB = {
+ init() {
+ for (let property of Object.keys(
+ this._genuine.QueryInterface(Ci.nsIX509CertDB)
+ )) {
+ if (property in this) {
+ continue;
+ }
+
+ if (typeof this._genuine[property] == "function") {
+ this[property] = this._genuine[property].bind(this._genuine);
+ }
+ }
+ },
+
+ openSignedAppFileAsync(root, file, callback) {
+ // First try calling the real cert DB
+ this._genuine.openSignedAppFileAsync(
+ root,
+ file,
+ (result, zipReader, cert) => {
+ verifyCert(file.clone(), result, cert, callback).then(
+ ([callback, result, cert]) => {
+ callback.openSignedAppFileFinished(result, zipReader, cert);
+ }
+ );
+ }
+ );
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIX509CertDB"]),
+ };
+
+ // Unregister the real database. This only works because the add-ons manager
+ // hasn't started up and grabbed the certificate database yet.
+ lazy.MockRegistrar.register(CERTDB_CONTRACTID, FakeCertDB);
+
+ // Initialize the mock service.
+ Cc[CERTDB_CONTRACTID].getService();
+ FakeCertDB.init();
+ },
+
+ /**
+ * Load the data from the specified files into the *real* blocklist providers.
+ * Loads using loadBlocklistRawData, which will treat this as an update.
+ *
+ * @param {nsIFile} dir
+ * The directory in which the files live.
+ * @param {string} prefix
+ * a prefix for the files which ought to be loaded.
+ * This method will suffix -extensions.json
+ * to the prefix it is given, and attempt to load it.
+ * If it exists, its data will be dumped into
+ * the respective store, and the update handler
+ * will be called.
+ */
+ async loadBlocklistData(dir, prefix) {
+ let loadedData = {};
+ let fileSuffix = "extensions";
+ const fileName = `${prefix}-${fileSuffix}.json`;
+
+ try {
+ loadedData[fileSuffix] = await IOUtils.readJSON(
+ PathUtils.join(dir.path, fileName)
+ );
+ this.info(`Loaded ${fileName}`);
+ } catch (e) {}
+
+ return this.loadBlocklistRawData(loadedData);
+ },
+
+ /**
+ * Load the following data into the *real* blocklist providers.
+ * Fires update methods as would happen if this data came from
+ * an actual blocklist update, etc.
+ *
+ * @param {object} data
+ * The data to load.
+ */
+ async loadBlocklistRawData(data) {
+ const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+ const blocklistMapping = {
+ extensions: BlocklistPrivate.ExtensionBlocklistRS,
+ extensionsMLBF: BlocklistPrivate.ExtensionBlocklistMLBF,
+ };
+
+ for (const [dataProp, blocklistObj] of Object.entries(blocklistMapping)) {
+ let newData = data[dataProp];
+ if (!newData) {
+ continue;
+ }
+ if (!Array.isArray(newData)) {
+ throw new Error(
+ "Expected an array of new items to put in the " +
+ dataProp +
+ " blocklist!"
+ );
+ }
+ for (let item of newData) {
+ if (!item.id) {
+ item.id = Services.uuid.generateUUID().number.slice(1, -1);
+ }
+ if (!item.last_modified) {
+ item.last_modified = Date.now();
+ }
+ }
+ blocklistObj.ensureInitialized();
+ let db = await blocklistObj._client.db;
+ const collectionTimestamp = Math.max(
+ ...newData.map(r => r.last_modified)
+ );
+ await db.importChanges({}, collectionTimestamp, newData, {
+ clear: true,
+ });
+ // We manually call _onUpdate... which is evil, but at the moment kinto doesn't have
+ // a better abstraction unless you want to mock your own http server to do the update.
+ await blocklistObj._onUpdate();
+ }
+ },
+
+ /**
+ * Starts up the add-on manager as if it was started by the application.
+ *
+ * @param {Object} params
+ * The new params are in an object and new code should use that.
+ * @param {boolean} params.earlyStartup
+ * Notifies early startup phase. default is true
+ * @param {boolean} params.lateStartup
+ * Notifies late startup phase which ensures addons are started or
+ * listeners are primed. default is true
+ * @param {boolean} params.newVersion
+ * If provided, the application version is changed to this string
+ * before the AddonManager is started.
+ */
+ async promiseStartupManager(params) {
+ if (this.addonIntegrationService) {
+ throw new Error(
+ "Attempting to startup manager that was already started."
+ );
+ }
+ // Support old arguments
+ if (typeof params != "object") {
+ params = {
+ newVersion: arguments[0],
+ };
+ }
+ let { earlyStartup = true, lateStartup = true, newVersion } = params;
+
+ lateStartup = earlyStartup && lateStartup;
+
+ if (newVersion) {
+ this.appInfo.version = newVersion;
+ this.appInfo.platformVersion = newVersion;
+ }
+
+ // AddonListeners are removed when the addonManager is shutdown,
+ // ensure the Extension observer is added. We call uninit in
+ // promiseShutdown to allow re-initialization.
+ lazy.ExtensionAddonObserver.init();
+
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+ XPIExports.XPIInternal.overrideAsyncShutdown(MockAsyncShutdown);
+
+ XPIExports.XPIInternal.BootstrapScope.prototype._beforeCallBootstrapMethod =
+ (method, params, reason) => {
+ try {
+ this.emit("bootstrap-method", { method, params, reason });
+ } catch (e) {
+ try {
+ this.testScope.do_throw(e);
+ } catch (e) {
+ // Le sigh.
+ }
+ }
+ };
+
+ this.addonIntegrationService = Cc[
+ "@mozilla.org/addons/integration;1"
+ ].getService(Ci.nsIObserver);
+
+ this.addonIntegrationService.observe(null, "addons-startup", null);
+
+ this.emit("addon-manager-started");
+
+ await Promise.all(XPIExports.XPIProvider.startupPromises);
+
+ // Load the add-ons list as it was after extension registration
+ await this.loadAddonsList(true);
+
+ // Wait for all add-ons to finish starting up before resolving.
+ await Promise.all(
+ Array.from(
+ XPIExports.XPIProvider.activeAddons.values(),
+ addon => addon.startupPromise
+ )
+ );
+ if (earlyStartup) {
+ lazy.ExtensionTestCommon.notifyEarlyStartup();
+ }
+ if (lateStartup) {
+ lazy.ExtensionTestCommon.notifyLateStartup();
+ }
+ },
+
+ async promiseShutdownManager({
+ clearOverrides = true,
+ clearL10nRegistry = true,
+ } = {}) {
+ if (!this.addonIntegrationService) {
+ return false;
+ }
+
+ if (this.overrideEntry && clearOverrides) {
+ this.overrideEntry.destruct();
+ this.overrideEntry = null;
+ }
+
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+
+ // Ensure some startup observers in XPIProvider are released.
+ Services.obs.notifyObservers(null, "test-load-xpi-database");
+
+ // Note: the code here used to trigger observer notifications such as
+ // "quit-application-granted". That was removed because of unwanted side
+ // effects in other components. The MockAsyncShutdown triggers here are very
+ // specific and only affect the AddonManager/XPIProvider internals.
+ await MockAsyncShutdown.quitApplicationGranted.trigger();
+
+ // If XPIDatabase.asyncLoadDB() has been called before, then _dbPromise is
+ // a promise, potentially still pending. Wait for it to settle before
+ // triggering profileBeforeChange, because the latter can trigger errors in
+ // the pending asyncLoadDB() by an indirect call to XPIDatabase.shutdown().
+ await XPIExports.XPIDatabase._dbPromise;
+
+ await MockAsyncShutdown.profileBeforeChange.trigger();
+ await MockAsyncShutdown.profileChangeTeardown.trigger();
+
+ this.emit("addon-manager-shutdown");
+
+ this.addonIntegrationService = null;
+
+ // Load the add-ons list as it was after application shutdown
+ await this.loadAddonsList();
+
+ // Flush the jar cache entries for each bootstrapped XPI so that
+ // we don't run into file locking issues on Windows.
+ for (let file of this.addonsList.xpis) {
+ Services.obs.notifyObservers(file, "flush-cache-entry");
+ }
+
+ // Clear L10nRegistry entries so restaring the AOM will work correctly with locales.
+ if (clearL10nRegistry) {
+ L10nRegistry.getInstance().clearSources();
+ }
+
+ // Clear any crash report annotations
+ this.appInfo.annotations = {};
+
+ // Force the XPIProvider provider to reload to better
+ // simulate real-world usage.
+
+ // This would be cleaner if I could get it as the rejection reason from
+ // the AddonManagerInternal.shutdown() promise
+ let shutdownError = XPIExports.XPIDatabase._saveError;
+
+ AddonManagerPrivate.unregisterProvider(XPIExports.XPIProvider);
+
+ resetXPIExports(XPIExports);
+
+ lazy.ExtensionAddonObserver.uninit();
+
+ lazy.ExtensionTestCommon.resetStartupPromises();
+
+ if (shutdownError) {
+ throw shutdownError;
+ }
+
+ return true;
+ },
+
+ /**
+ * Asynchronously restart the AddonManager. If newVersion is provided,
+ * simulate an application upgrade (or downgrade) where the version
+ * is changed to newVersion when re-started.
+ *
+ * @param {Object} params
+ * The new params are in an object and new code should use that.
+ * See promiseStartupManager for param details.
+ */
+ async promiseRestartManager(params) {
+ await this.promiseShutdownManager({ clearOverrides: false });
+ await this.promiseStartupManager(params);
+ },
+
+ /**
+ * If promiseStartupManager is called with earlyStartup: false, then
+ * use this to notify early startup.
+ *
+ * @returns {Promise} resolves when notification is complete
+ */
+ notifyEarlyStartup() {
+ return lazy.ExtensionTestCommon.notifyEarlyStartup();
+ },
+
+ /**
+ * If promiseStartupManager is called with lateStartup: false, then
+ * use this to notify late startup. You should also call early startup
+ * if necessary.
+ *
+ * @returns {Promise} resolves when notification is complete
+ */
+ notifyLateStartup() {
+ return lazy.ExtensionTestCommon.notifyLateStartup();
+ },
+
+ async loadAddonsList(flush = false) {
+ if (flush) {
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+ XPIExports.XPIInternal.XPIStates.save();
+ await XPIExports.XPIInternal.XPIStates._jsonFile._save();
+ }
+
+ this.addonsList = new AddonsList(this.addonStartup);
+ },
+
+ /**
+ * Writes the given data to a file in the given zip file.
+ *
+ * @param {string|nsIFile} zipFile
+ * The zip file to write to.
+ * @param {Object} files
+ * An object containing filenames and the data to write to the
+ * corresponding paths in the zip file.
+ * @param {integer} [flags = 0]
+ * Additional flags to open the file with.
+ */
+ writeFilesToZip(zipFile, files, flags = 0) {
+ if (typeof zipFile == "string") {
+ zipFile = nsFile(zipFile);
+ }
+
+ var zipW = ZipWriter(
+ zipFile,
+ FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE | flags
+ );
+
+ for (let [path, data] of Object.entries(files)) {
+ if (
+ typeof data === "object" &&
+ ChromeUtils.getClassName(data) === "Object"
+ ) {
+ data = JSON.stringify(data);
+ }
+ if (!(data instanceof ArrayBuffer)) {
+ data = new TextEncoder().encode(data).buffer;
+ }
+
+ let stream = ArrayBufferInputStream(data, 0, data.byteLength);
+
+ // Note these files are being created in the XPI archive with date
+ // 1 << 49, which is a valid time for ZipWriter.
+ zipW.addEntryStream(
+ path,
+ Math.pow(2, 49),
+ Ci.nsIZipWriter.COMPRESSION_NONE,
+ stream,
+ false
+ );
+ }
+
+ zipW.close();
+ },
+
+ async promiseWriteFilesToZip(zip, files, flags) {
+ await IOUtils.makeDirectory(PathUtils.parent(zip));
+
+ this.writeFilesToZip(zip, files, flags);
+
+ return Promise.resolve(nsFile(zip));
+ },
+
+ async promiseWriteFilesToDir(dir, files) {
+ await IOUtils.makeDirectory(dir);
+
+ for (let [path, data] of Object.entries(files)) {
+ path = path.split("/");
+ let leafName = path.pop();
+
+ // Create parent directories, if necessary.
+ let dirPath = dir;
+ for (let subDir of path) {
+ dirPath = PathUtils.join(dirPath, subDir);
+ await PathUtils.makeDirectory(dirPath);
+ }
+
+ const leafPath = PathUtils.join(dirPath, leafName);
+ if (
+ typeof data == "object" &&
+ ChromeUtils.getClassName(data) == "Object"
+ ) {
+ await IOUtils.writeJSON(leafPath, data);
+ } else if (typeof data == "string") {
+ await IOUtils.writeUTF8(leafPath, data);
+ }
+ }
+
+ return nsFile(dir);
+ },
+
+ promiseWriteFilesToExtension(dir, id, files, unpacked = this.testUnpacked) {
+ if (unpacked) {
+ let path = PathUtils.join(dir, id);
+
+ return this.promiseWriteFilesToDir(path, files);
+ }
+
+ let xpi = PathUtils.join(dir, `${id}.xpi`);
+
+ return this.promiseWriteFilesToZip(xpi, files);
+ },
+
+ tempXPIs: [],
+
+ allocTempXPIFile() {
+ let file = this.tempDir.clone();
+ let uuid = Services.uuid.generateUUID().number.slice(1, -1);
+ file.append(`${uuid}.xpi`);
+
+ this.tempXPIs.push(file);
+
+ return file;
+ },
+
+ /**
+ * Creates an XPI file for some manifest data in the temporary directory and
+ * returns the nsIFile for it. The file will be deleted when the test completes.
+ *
+ * @param {object} files
+ * The object holding data about the add-on
+ * @return {nsIFile} A file pointing to the created XPI file
+ */
+ createTempXPIFile(files) {
+ let file = this.allocTempXPIFile();
+ this.writeFilesToZip(file.path, files);
+ return file;
+ },
+
+ /**
+ * Creates an XPI file for some WebExtension data in the temporary directory and
+ * returns the nsIFile for it. The file will be deleted when the test completes.
+ *
+ * @param {Object} data
+ * The object holding data about the add-on, as expected by
+ * |ExtensionTestCommon.generateXPI|.
+ * @return {nsIFile} A file pointing to the created XPI file
+ */
+ createTempWebExtensionFile(data) {
+ let file = lazy.ExtensionTestCommon.generateXPI(data);
+ this.tempXPIs.push(file);
+ return file;
+ },
+
+ /**
+ * Creates an XPI with the given files and installs it.
+ *
+ * @param {object} files
+ * A files object as would be passed to {@see #createTempXPI}.
+ * @returns {Promise}
+ * A promise which resolves when the add-on is installed.
+ */
+ promiseInstallXPI(files) {
+ return this.promiseInstallFile(this.createTempXPIFile(files));
+ },
+
+ /**
+ * Creates an extension proxy file.
+ * See: https://developer.mozilla.org/en-US/Add-ons/Setting_up_extension_development_environment#Firefox_extension_proxy_file
+ *
+ * @param {nsIFile} dir
+ * The directory to add the proxy file to.
+ * @param {nsIFile} addon
+ * An nsIFile for the add-on file that this is a proxy file for.
+ * @param {string} id
+ * A string to use for the add-on ID.
+ * @returns {Promise} Resolves when the file has been created.
+ */
+ promiseWriteProxyFileToDir(dir, addon, id) {
+ let files = {
+ [id]: addon.path,
+ };
+
+ return this.promiseWriteFilesToDir(dir.path, files);
+ },
+
+ /**
+ * Manually installs an XPI file into an install location by either copying the
+ * XPI there or extracting it depending on whether unpacking is being tested
+ * or not.
+ *
+ * @param {nsIFile} xpiFile
+ * The XPI file to install.
+ * @param {nsIFile} [installLocation = this.profileExtensions]
+ * The install location (an nsIFile) to install into.
+ * @param {string} [id]
+ * The ID to install as.
+ * @param {boolean} [unpacked = this.testUnpacked]
+ * If true, install as an unpacked directory, rather than a
+ * packed XPI.
+ * @returns {nsIFile}
+ * A file pointing to the installed location of the XPI file or
+ * unpacked directory.
+ */
+ async manuallyInstall(
+ xpiFile,
+ installLocation = this.profileExtensions,
+ id = null,
+ unpacked = this.testUnpacked
+ ) {
+ if (id == null) {
+ id = await this.getIDFromExtension(xpiFile);
+ }
+
+ if (unpacked) {
+ let dir = installLocation.clone();
+ dir.append(id);
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ let zip = ZipReader(xpiFile);
+ for (let entry of zip.findEntries(null)) {
+ let target = dir.clone();
+ for (let part of entry.split("/")) {
+ target.append(part);
+ }
+ if (!target.parent.exists()) {
+ target.parent.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ }
+ try {
+ zip.extract(entry, target);
+ } catch (e) {
+ if (
+ e.result != Cr.NS_ERROR_FILE_DIR_NOT_EMPTY &&
+ !(target.exists() && target.isDirectory())
+ ) {
+ throw e;
+ }
+ }
+ target.permissions |= FileUtils.PERMS_FILE;
+ }
+ zip.close();
+
+ return dir;
+ }
+
+ let target = installLocation.clone();
+ target.append(`${id}.xpi`);
+ xpiFile.copyTo(target.parent, target.leafName);
+ return target;
+ },
+
+ /**
+ * Manually uninstalls an add-on by removing its files from the install
+ * location.
+ *
+ * @param {nsIFile} installLocation
+ * The nsIFile of the install location to remove from.
+ * @param {string} id
+ * The ID of the add-on to remove.
+ * @param {boolean} [unpacked = this.testUnpacked]
+ * If true, uninstall an unpacked directory, rather than a
+ * packed XPI.
+ */
+ manuallyUninstall(installLocation, id, unpacked = this.testUnpacked) {
+ let file = this.getFileForAddon(installLocation, id, unpacked);
+
+ // In reality because the app is restarted a flush isn't necessary for XPIs
+ // removed outside the app, but for testing we must flush manually.
+ if (file.isFile()) {
+ Services.obs.notifyObservers(file, "flush-cache-entry");
+ }
+
+ file.remove(true);
+ },
+
+ /**
+ * Gets the nsIFile for where an add-on is installed. It may point to a file or
+ * a directory depending on whether add-ons are being installed unpacked or not.
+ *
+ * @param {nsIFile} dir
+ * The nsIFile for the install location
+ * @param {string} id
+ * The ID of the add-on
+ * @param {boolean} [unpacked = this.testUnpacked]
+ * If true, return the path to an unpacked directory, rather than a
+ * packed XPI.
+ * @returns {nsIFile}
+ * A file pointing to the XPI file or unpacked directory where
+ * the add-on should be installed.
+ */
+ getFileForAddon(dir, id, unpacked = this.testUnpacked) {
+ dir = dir.clone();
+ if (unpacked) {
+ dir.append(id);
+ } else {
+ dir.append(`${id}.xpi`);
+ }
+ return dir;
+ },
+
+ /**
+ * Sets the last modified time of the extension, usually to trigger an update
+ * of its metadata.
+ *
+ * @param {nsIFile} ext A file pointing to either the packed extension or its unpacked directory.
+ * @param {number} time The time to which we set the lastModifiedTime of the extension
+ *
+ * @deprecated Please use promiseSetExtensionModifiedTime instead
+ */
+ setExtensionModifiedTime(ext, time) {
+ ext.lastModifiedTime = time;
+ if (ext.isDirectory()) {
+ for (let file of this.iterDirectory(ext)) {
+ this.setExtensionModifiedTime(file, time);
+ }
+ }
+ },
+
+ async promiseSetExtensionModifiedTime(path, time) {
+ await IOUtils.setModificationTime(path, time);
+
+ const stat = await IOUtils.stat(path);
+ if (stat.type !== "directory") {
+ return;
+ }
+
+ const children = await IOUtils.getChildren(path);
+
+ try {
+ await Promise.all(
+ children.map(entry => this.promiseSetExtensionModifiedTime(entry, time))
+ );
+ } catch (ex) {
+ if (DOMException.isInstance(ex)) {
+ return;
+ }
+ throw ex;
+ }
+ },
+
+ registerDirectory(key, dir) {
+ var dirProvider = {
+ getFile(prop, persistent) {
+ persistent.value = false;
+ if (prop == key) {
+ return dir.clone();
+ }
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
+ };
+ Services.dirsvc.registerProvider(dirProvider);
+
+ try {
+ Services.dirsvc.undefine(key);
+ } catch (e) {
+ // This throws if the key is not already registered, but that
+ // doesn't matter.
+ if (e.result != Cr.NS_ERROR_FAILURE) {
+ throw e;
+ }
+ }
+ },
+
+ /**
+ * Returns a promise that resolves when the given add-on event is fired. The
+ * resolved value is an array of arguments passed for the event.
+ *
+ * @param {string} event
+ * The name of the AddonListener event handler method for which
+ * an event is expected.
+ * @param {function} checkFn [optional]
+ * A function to check if this is the right event. Should return true
+ * for the event that it wants, false otherwise. Will be passed
+ * all the relevant arguments.
+ * If not passed, any event will do to resolve the promise.
+ * @returns {Promise<Array>}
+ * Resolves to an array containing the event handler's
+ * arguments the first time it is called.
+ */
+ promiseAddonEvent(event, checkFn) {
+ return new Promise(resolve => {
+ let listener = {
+ [event](...args) {
+ if (typeof checkFn == "function" && !checkFn(...args)) {
+ return;
+ }
+ AddonManager.removeAddonListener(listener);
+ resolve(args);
+ },
+ };
+
+ AddonManager.addAddonListener(listener);
+ });
+ },
+
+ promiseInstallEvent(event, checkFn) {
+ return new Promise(resolve => {
+ let listener = {
+ [event](...args) {
+ if (typeof checkFn == "function" && !checkFn(...args)) {
+ return;
+ }
+ AddonManager.removeInstallListener(listener);
+ resolve(args);
+ },
+ };
+
+ AddonManager.addInstallListener(listener);
+ });
+ },
+
+ /**
+ * A helper method to install AddonInstall and wait for completion.
+ *
+ * @param {AddonInstall} install
+ * The add-on to install.
+ * @returns {Promise<AddonInstall>}
+ * Resolves when the install completes, either successfully or
+ * in failure.
+ */
+ promiseCompleteInstall(install) {
+ let listener;
+ return new Promise(resolve => {
+ let installPromise;
+ listener = {
+ onDownloadFailed: resolve,
+ onDownloadCancelled: resolve,
+ onInstallFailed: resolve,
+ onInstallCancelled: resolve,
+ onInstallEnded() {
+ // onInstallEnded is called right when an add-on has been installed.
+ // install() may still be pending, e.g. for updates, and be awaiting
+ // the completion of the update, part of which is the removal of the
+ // temporary XPI file of the downloaded update. To avoid intermittent
+ // test failures due to lingering temporary files, await install().
+ resolve(installPromise);
+ },
+ onInstallPostponed: resolve,
+ };
+
+ install.addListener(listener);
+ installPromise = install.install();
+ }).then(() => {
+ install.removeListener(listener);
+ return install;
+ });
+ },
+
+ /**
+ * A helper method to install a file.
+ *
+ * @param {nsIFile} file
+ * The file to install
+ * @param {boolean} [ignoreIncompatible = false]
+ * Optional parameter to ignore add-ons that are incompatible
+ * with the application
+ * @param {Object} [installTelemetryInfo = undefined]
+ * Optional parameter to set the install telemetry info for the
+ * installed addon
+ * @returns {Promise}
+ * Resolves when the install has completed.
+ */
+ async promiseInstallFile(
+ file,
+ ignoreIncompatible = false,
+ installTelemetryInfo
+ ) {
+ let install = await AddonManager.getInstallForFile(
+ file,
+ null,
+ installTelemetryInfo
+ );
+ if (!install) {
+ throw new Error(`No AddonInstall created for ${file.path}`);
+ }
+
+ if (install.state != AddonManager.STATE_DOWNLOADED) {
+ throw new Error(
+ `Expected file to be downloaded for install of ${file.path}`
+ );
+ }
+
+ if (ignoreIncompatible && install.addon.appDisabled) {
+ return null;
+ }
+
+ await install.install();
+ return install;
+ },
+
+ /**
+ * A helper method to install an array of files.
+ *
+ * @param {Iterable<nsIFile>} files
+ * The files to install
+ * @param {boolean} [ignoreIncompatible = false]
+ * Optional parameter to ignore add-ons that are incompatible
+ * with the application
+ * @returns {Promise}
+ * Resolves when the installs have completed.
+ */
+ promiseInstallAllFiles(files, ignoreIncompatible = false) {
+ return Promise.all(
+ Array.from(files, file =>
+ this.promiseInstallFile(file, ignoreIncompatible)
+ )
+ );
+ },
+
+ promiseCompleteAllInstalls(installs) {
+ return Promise.all(Array.from(installs, this.promiseCompleteInstall));
+ },
+
+ /**
+ * @property {number} updateReason
+ * The default update reason for {@see promiseFindAddonUpdates}
+ * calls. May be overwritten by tests which primarily check for
+ * updates with a particular reason.
+ */
+ updateReason: AddonManager.UPDATE_WHEN_PERIODIC_UPDATE,
+
+ /**
+ * Returns a promise that will be resolved when an add-on update check is
+ * complete. The value resolved will be an AddonInstall if a new version was
+ * found.
+ *
+ * @param {object} addon The add-on to find updates for.
+ * @param {integer} reason The type of update to find.
+ * @param {Array} args Additional args to pass to `checkUpdates` after
+ * the update reason.
+ * @return {Promise<object>} an object containing information about the update.
+ */
+ promiseFindAddonUpdates(
+ addon,
+ reason = AddonTestUtils.updateReason,
+ ...args
+ ) {
+ // Retrieve the test assertion helper from the testScope
+ // (which is `equal` in xpcshell-test and `is` in mochitest)
+ let equal = this.testScope.equal || this.testScope.is;
+ return new Promise((resolve, reject) => {
+ let result = {};
+ addon.findUpdates(
+ {
+ onNoCompatibilityUpdateAvailable(addon2) {
+ if ("compatibilityUpdate" in result) {
+ throw new Error("Saw multiple compatibility update events");
+ }
+ equal(addon, addon2, "onNoCompatibilityUpdateAvailable");
+ result.compatibilityUpdate = false;
+ },
+
+ onCompatibilityUpdateAvailable(addon2) {
+ if ("compatibilityUpdate" in result) {
+ throw new Error("Saw multiple compatibility update events");
+ }
+ equal(addon, addon2, "onCompatibilityUpdateAvailable");
+ result.compatibilityUpdate = true;
+ },
+
+ onNoUpdateAvailable(addon2) {
+ if ("updateAvailable" in result) {
+ throw new Error("Saw multiple update available events");
+ }
+ equal(addon, addon2, "onNoUpdateAvailable");
+ result.updateAvailable = false;
+ },
+
+ onUpdateAvailable(addon2, install) {
+ if ("updateAvailable" in result) {
+ throw new Error("Saw multiple update available events");
+ }
+ equal(addon, addon2, "onUpdateAvailable");
+ result.updateAvailable = install;
+ },
+
+ onUpdateFinished(addon2, error) {
+ equal(addon, addon2, "onUpdateFinished");
+ if (error == AddonManager.UPDATE_STATUS_NO_ERROR) {
+ resolve(result);
+ } else {
+ result.error = error;
+ reject(result);
+ }
+ },
+ },
+ reason,
+ ...args
+ );
+ });
+ },
+
+ /**
+ * Monitors console output for the duration of a task, and returns a promise
+ * which resolves to a tuple containing a list of all console messages
+ * generated during the task's execution, and the result of the task itself.
+ *
+ * @param {function} task
+ * The task to run while monitoring console output. May be
+ * an async function, or an ordinary function which returns a promose.
+ * @return {Promise<[Array<nsIConsoleMessage>, *]>}
+ * Resolves to an object containing a `messages` property, with
+ * the array of console messages emitted during the execution
+ * of the task, and a `result` property, containing the task's
+ * return value.
+ */
+ async promiseConsoleOutput(task) {
+ const DONE = "=== xpcshell test console listener done ===";
+
+ let listener,
+ messages = [];
+ let awaitListener = new Promise(resolve => {
+ listener = msg => {
+ if (msg == DONE) {
+ resolve();
+ } else {
+ msg instanceof Ci.nsIScriptError;
+ messages.push(msg);
+ }
+ };
+ });
+
+ Services.console.registerListener(listener);
+ try {
+ let result = await task();
+
+ Services.console.logStringMessage(DONE);
+ await awaitListener;
+
+ return { messages, result };
+ } finally {
+ Services.console.unregisterListener(listener);
+ }
+ },
+
+ /**
+ * An object describing an expected or forbidden console message. Each
+ * property in the object corresponds to a property with the same name
+ * in a console message. If the value in the pattern object is a
+ * regular expression, it must match the value of the corresponding
+ * console message property. If it is any other value, it must be
+ * strictly equal to the correspondng console message property.
+ *
+ * @typedef {object} ConsoleMessagePattern
+ */
+
+ /**
+ * Checks the list of messages returned from `promiseConsoleOutput`
+ * against the given set of expected messages.
+ *
+ * This is roughly equivalent to the expected and forbidden message
+ * matching functionality of SimpleTest.monitorConsole.
+ *
+ * @param {Array<object>} messages
+ * The array of console messages to match.
+ * @param {object} options
+ * Options describing how to perform the match.
+ * @param {Array<ConsoleMessagePattern>} [options.expected = []]
+ * An array of messages which must appear in `messages`. The
+ * matching messages in the `messages` array must appear in the
+ * same order as the patterns in the `expected` array.
+ * @param {Array<ConsoleMessagePattern>} [options.forbidden = []]
+ * An array of messages which must not appear in the `messages`
+ * array.
+ * @param {bool} [options.forbidUnexpected = false]
+ * If true, the `messages` array must not contain any messages
+ * which are not matched by the given `expected` patterns.
+ */
+ checkMessages(
+ messages,
+ { expected = [], forbidden = [], forbidUnexpected = false }
+ ) {
+ function msgMatches(msg, expectedMsg) {
+ for (let [prop, pattern] of Object.entries(expectedMsg)) {
+ if (isRegExp(pattern) && typeof msg[prop] === "string") {
+ if (!pattern.test(msg[prop])) {
+ return false;
+ }
+ } else if (msg[prop] !== pattern) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ function validateOptionFormat(optionName, optionValue) {
+ for (let item of optionValue) {
+ if (!item || typeof item !== "object" || isRegExp(item)) {
+ throw new Error(
+ `Unexpected format in AddonTestUtils.checkMessages "${optionName}" parameter`
+ );
+ }
+ }
+ }
+
+ validateOptionFormat("expected", expected);
+ validateOptionFormat("forbidden", forbidden);
+
+ let i = 0;
+ for (let msg of messages) {
+ if (forbidden.some(pat => msgMatches(msg, pat))) {
+ this.testScope.ok(false, `Got forbidden console message: ${msg}`);
+ continue;
+ }
+
+ if (i < expected.length && msgMatches(msg, expected[i])) {
+ this.info(`Matched expected console message: ${msg}`);
+ i++;
+ } else if (forbidUnexpected) {
+ this.testScope.ok(false, `Got unexpected console message: ${msg}`);
+ }
+ }
+ for (let pat of expected.slice(i)) {
+ this.testScope.ok(
+ false,
+ `Did not get expected console message: ${uneval(pat)}`
+ );
+ }
+ },
+
+ /**
+ * Asserts that the expected installTelemetryInfo properties are available
+ * on the AddonWrapper or AddonInstall objects.
+ *
+ * @param {AddonWrapper|AddonInstall} addonOrInstall
+ * The addon or addonInstall object to check.
+ * @param {Object} expectedInstallInfo
+ * The expected installTelemetryInfo properties
+ * (every property can be a primitive value or a regular expression).
+ * @param {string} [msg]
+ * Optional assertion message suffix.
+ */
+ checkInstallInfo(addonOrInstall, expectedInstallInfo, msg = undefined) {
+ const installInfo = addonOrInstall.installTelemetryInfo;
+ const { Assert } = this.testScope;
+
+ msg = msg ? ` ${msg}` : "";
+
+ for (const key of Object.keys(expectedInstallInfo)) {
+ const actual = installInfo[key];
+ let expected = expectedInstallInfo[key];
+
+ // Assert the property value using a regular expression.
+ if (expected && typeof expected.test == "function") {
+ Assert.ok(
+ expected.test(actual),
+ `${key} value "${actual}" has the value expected "${expected}"${msg}`
+ );
+ } else {
+ Assert.deepEqual(
+ actual,
+ expected,
+ `Got the expected value for ${key}${msg}`
+ );
+ }
+ }
+ },
+
+ /**
+ * Helper to wait for a webextension to completely start
+ *
+ * @param {string} [id]
+ * An optional extension id to look for.
+ *
+ * @returns {Promise<Extension>}
+ * A promise that resolves with the extension, once it is started.
+ */
+ promiseWebExtensionStartup(id) {
+ return new Promise(resolve => {
+ lazy.Management.on("ready", function listener(event, extension) {
+ if (!id || extension.id == id) {
+ lazy.Management.off("ready", listener);
+ resolve(extension);
+ }
+ });
+ });
+ },
+
+ /**
+ * Wait until an extension with a search provider has been loaded.
+ * This should be called after the extension has started, but before shutdown.
+ *
+ * @param {object} extension
+ * The return value of ExtensionTestUtils.loadExtension.
+ * For browser tests, see mochitest/tests/SimpleTest/ExtensionTestUtils.js
+ * For xpcshell tests, see toolkit/components/extensions/ExtensionXPCShellUtils.jsm
+ * @param {object} [options]
+ * Optional options.
+ * @param {boolean} [options.expectPending = false]
+ * Whether to expect the search provider to still be starting up.
+ */
+ async waitForSearchProviderStartup(
+ extension,
+ { expectPending = false } = {}
+ ) {
+ // In xpcshell tests, equal/ok are defined in the global scope.
+ let { equal, ok } = this.testScope;
+ if (!equal || !ok) {
+ // In mochitests, these are available via Assert.sys.mjs.
+ let { Assert } = this.testScope;
+ equal = Assert.equal.bind(Assert);
+ ok = Assert.ok.bind(Assert);
+ }
+
+ equal(
+ extension.state,
+ "running",
+ "Search provider extension should be running"
+ );
+ ok(extension.id, "Extension ID of search provider should be set");
+
+ // The map of promises from browser/components/extensions/parent/ext-chrome-settings-overrides.js
+ let { pendingSearchSetupTasks } = lazy.Management.global;
+ let searchStartupPromise = pendingSearchSetupTasks.get(extension.id);
+ if (expectPending) {
+ ok(
+ searchStartupPromise,
+ "Search provider registration should be in progress"
+ );
+ }
+ return searchStartupPromise;
+ },
+
+ /**
+ * Initializes the URLPreloader, which is required in order to load
+ * built_in_addons.json.
+ */
+ initializeURLPreloader() {
+ lazy.aomStartup.initializeURLPreloader();
+ },
+
+ /**
+ * Override chrome URL for specifying allowed built-in add-ons.
+ *
+ * @param {object} data - An object specifying which add-on IDs are permitted
+ * to load, for instance: { "system": ["id1", "..."] }
+ */
+ async overrideBuiltIns(data) {
+ this.initializeURLPreloader();
+
+ let file = this.tempDir.clone();
+ file.append("override.txt");
+ this.tempXPIs.push(file);
+
+ let manifest = Services.io.newFileURI(file);
+ await IOUtils.writeJSON(file.path, data);
+ this.overrideEntry = lazy.aomStartup.registerChrome(manifest, [
+ [
+ "override",
+ "chrome://browser/content/built_in_addons.json",
+ Services.io.newFileURI(file).spec,
+ ],
+ ]);
+ },
+
+ // AMTelemetry events helpers.
+
+ /**
+ * Formerly this function re-routed telemetry events. Now it just ensures
+ * that there are no unexamined events after the test file is exiting.
+ */
+ hookAMTelemetryEvents() {
+ this.testScope.registerCleanupFunction(() => {
+ this.testScope.Assert.deepEqual(
+ [],
+ this.getAMTelemetryEvents(),
+ "No unexamined telemetry events after test is finished"
+ );
+ });
+ },
+
+ /**
+ * Retrive any AMTelemetry event collected and clears _all_ telemetry events.
+ *
+ * @returns {Array<Object>}
+ * The array of the collected telemetry data.
+ */
+ getAMTelemetryEvents() {
+ // This duplicates some logic from TelemetryTestUtils.
+ let snapshots = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ /* clear = */ true
+ );
+ let events = (snapshots.parent ?? [])
+ .filter(entry => entry[1] == "addonsManager")
+ .map(entry => ({
+ // The callers don't expect the timestamp or the category.
+ method: entry[2],
+ object: entry[3],
+ value: entry[4],
+ extra: entry[5],
+ }));
+
+ return events;
+ },
+
+ /**
+ * @param {string|string[]} events - The event(s) to retrieve.
+ * @param {object} [filter] - key/value pairs to filter events.
+ * @returns {object[]} Collected extra objects from events.
+ */
+ getAMGleanEvents(events, filter = {}) {
+ let result = [];
+ for (let event of [].concat(events)) {
+ result = result.concat(Glean.addonsManager[event].testGetValue() ?? []);
+ }
+
+ // When combining multiple events, we want them in chronological order.
+ result.sort((a, b) => a.timestamp - b.timestamp);
+
+ result = result.filter(e =>
+ Object.keys(filter).every(key => e.extra[key] === filter[key])
+ );
+
+ // We (usually) don't care about install_id, so drop it to ease comparison.
+ result.forEach(e => delete e.extra.install_id);
+
+ // For Glean events, all data is in the extra object.
+ return result.map(e => e.extra);
+ },
+};
+
+for (let [key, val] of Object.entries(AddonTestUtils)) {
+ if (typeof val == "function") {
+ AddonTestUtils[key] = val.bind(AddonTestUtils);
+ }
+}
+
+EventEmitter.decorate(AddonTestUtils);
diff --git a/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs
new file mode 100644
index 0000000000..a3935a26f9
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs
@@ -0,0 +1,643 @@
+/* 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/. */
+
+/**
+ * The AddonUpdateChecker is responsible for retrieving the update information
+ * from an add-on's remote update manifest.
+ */
+
+const TIMEOUT = 60 * 1000;
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ Blocklist: "resource://gre/modules/Blocklist.sys.mjs",
+ CertUtils: "resource://gre/modules/CertUtils.sys.mjs",
+ ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
+});
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.update-checker";
+
+// Create a new logger for use by the Addons Update Checker
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+/**
+ * Sanitizes the update URL in an update item, as returned by
+ * parseRDFManifest and parseJSONManifest. Ensures that:
+ *
+ * - The URL is secure, or secured by a strong enough hash.
+ * - The security principal of the update manifest has permission to
+ * load the URL.
+ *
+ * @param aUpdate
+ * The update item to sanitize.
+ * @param aRequest
+ * The XMLHttpRequest used to load the manifest.
+ * @param aHashPattern
+ * The regular expression used to validate the update hash.
+ * @param aHashString
+ * The human-readable string specifying which hash functions
+ * are accepted.
+ */
+function sanitizeUpdateURL(aUpdate, aRequest, aHashPattern, aHashString) {
+ if (aUpdate.updateURL) {
+ let scriptSecurity = Services.scriptSecurityManager;
+ let principal = scriptSecurity.getChannelURIPrincipal(aRequest.channel);
+ try {
+ // This logs an error on failure, so no need to log it a second time
+ scriptSecurity.checkLoadURIStrWithPrincipal(
+ principal,
+ aUpdate.updateURL,
+ scriptSecurity.DISALLOW_SCRIPT
+ );
+ } catch (e) {
+ delete aUpdate.updateURL;
+ return;
+ }
+
+ if (
+ lazy.AddonManager.checkUpdateSecurity &&
+ !aUpdate.updateURL.startsWith("https:") &&
+ !aHashPattern.test(aUpdate.updateHash)
+ ) {
+ logger.warn(
+ `Update link ${aUpdate.updateURL} is not secure and is not verified ` +
+ `by a strong enough hash (needs to be ${aHashString}).`
+ );
+ delete aUpdate.updateURL;
+ delete aUpdate.updateHash;
+ }
+ }
+}
+
+/**
+ * Parses an JSON update manifest into an array of update objects.
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aRequest
+ * The XMLHttpRequest that has retrieved the update manifest
+ * @param aManifestData
+ * The pre-parsed manifest, as a JSON object tree
+ * @return an array of update objects
+ * @throws if the update manifest is invalid in any way
+ */
+function parseJSONManifest(aId, aRequest, aManifestData) {
+ let TYPE_CHECK = {
+ array: val => Array.isArray(val),
+ object: val => val && typeof val == "object" && !Array.isArray(val),
+ };
+
+ function getProperty(aObj, aProperty, aType, aDefault = undefined) {
+ if (!(aProperty in aObj)) {
+ return aDefault;
+ }
+
+ let value = aObj[aProperty];
+
+ let matchesType =
+ aType in TYPE_CHECK ? TYPE_CHECK[aType](value) : typeof value == aType;
+ if (!matchesType) {
+ throw Components.Exception(
+ `Update manifest property '${aProperty}' has incorrect type (expected ${aType})`
+ );
+ }
+
+ return value;
+ }
+
+ function getRequiredProperty(aObj, aProperty, aType) {
+ let value = getProperty(aObj, aProperty, aType);
+ if (value === undefined) {
+ throw Components.Exception(
+ `Update manifest is missing a required ${aProperty} property.`
+ );
+ }
+ return value;
+ }
+
+ let manifest = aManifestData;
+
+ if (!TYPE_CHECK.object(manifest)) {
+ throw Components.Exception(
+ "Root element of update manifest must be a JSON object literal"
+ );
+ }
+
+ // The set of add-ons this manifest has updates for
+ let addons = getProperty(manifest, "addons", "object");
+
+ if (!addons) {
+ let keys = Object.keys(manifest);
+ if (keys.length) {
+ // "addons" property is optional. The presence of other properties may be
+ // a sign of a mistake, so print a warning to help with debugging.
+ logger.warn(
+ `Update manifest for ${aId} is missing the "addons" property, found ${keys} instead.`
+ );
+ } else {
+ // If the add-on isn't listed, the update server may return an empty
+ // response.
+ logger.warn(`Received empty update manifest for ${aId}.`);
+ }
+ return [];
+ }
+
+ // The entry for this particular add-on
+ let addon = getProperty(addons, aId, "object");
+
+ // A missing entry doesn't count as a failure, just as no avialable update
+ // information
+ if (!addon) {
+ logger.warn("Update manifest did not contain an entry for " + aId);
+ return [];
+ }
+
+ // The list of available updates
+ let updates = getProperty(addon, "updates", "array", []);
+
+ let results = [];
+
+ for (let update of updates) {
+ let version = getRequiredProperty(update, "version", "string");
+
+ logger.debug(`Found an update entry for ${aId} version ${version}`);
+
+ let applications = getProperty(update, "applications", "object", {
+ gecko: {},
+ });
+
+ // "gecko" is currently the only supported application entry. If
+ // it's missing, skip this update.
+ if (!("gecko" in applications)) {
+ logger.debug(
+ "gecko not in application entry, skipping update of ${addon}"
+ );
+ continue;
+ }
+
+ let app = getProperty(applications, "gecko", "object");
+
+ let appEntry = {
+ id: TOOLKIT_ID,
+ minVersion: getProperty(
+ app,
+ "strict_min_version",
+ "string",
+ lazy.AddonManagerPrivate.webExtensionsMinPlatformVersion
+ ),
+ maxVersion: "*",
+ };
+
+ let result = {
+ id: aId,
+ version,
+ updateURL: getProperty(update, "update_link", "string"),
+ updateHash: getProperty(update, "update_hash", "string"),
+ updateInfoURL: getProperty(update, "update_info_url", "string"),
+ strictCompatibility: false,
+ targetApplications: [appEntry],
+ };
+
+ if ("strict_max_version" in app) {
+ if ("advisory_max_version" in app) {
+ logger.warn(
+ "Ignoring 'advisory_max_version' update manifest property for " +
+ aId +
+ " property since 'strict_max_version' also present"
+ );
+ }
+
+ appEntry.maxVersion = getProperty(app, "strict_max_version", "string");
+ result.strictCompatibility = appEntry.maxVersion != "*";
+ } else if ("advisory_max_version" in app) {
+ appEntry.maxVersion = getProperty(app, "advisory_max_version", "string");
+ }
+
+ // Add an app entry for the current API ID, too, so that it overrides any
+ // existing app-specific entries, which would take priority over the toolkit
+ // entry.
+ //
+ // Note: This currently only has any effect on legacy extensions (mainly
+ // those used in tests), since WebExtensions cannot yet specify app-specific
+ // compatibility ranges.
+ result.targetApplications.push(
+ Object.assign({}, appEntry, { id: Services.appinfo.ID })
+ );
+
+ // The JSON update protocol requires an SHA-2 hash. RDF still
+ // supports SHA-1, for compatibility reasons.
+ sanitizeUpdateURL(result, aRequest, /^sha(256|512):/, "sha256 or sha512");
+
+ results.push(result);
+ }
+ return results;
+}
+
+/**
+ * Starts downloading an update manifest and then passes it to an appropriate
+ * parser to convert to an array of update objects
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aUrl
+ * The URL of the update manifest
+ * @param aObserver
+ * An observer to pass results to
+ */
+function UpdateParser(aId, aUrl, aObserver) {
+ this.id = aId;
+ this.observer = aObserver;
+ this.url = aUrl;
+
+ logger.debug("Requesting " + aUrl);
+ try {
+ this.request = new lazy.ServiceRequest({ mozAnon: true });
+ this.request.open("GET", this.url, true);
+ this.request.channel.notificationCallbacks =
+ new lazy.CertUtils.BadCertHandler(
+ !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS
+ );
+ this.request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ // Prevent the request from writing to cache.
+ this.request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
+ this.request.overrideMimeType("text/plain");
+ this.request.timeout = TIMEOUT;
+ this.request.addEventListener("load", () => this.onLoad());
+ this.request.addEventListener("error", () => this.onError());
+ this.request.addEventListener("timeout", () => this.onTimeout());
+ this.request.send(null);
+ } catch (e) {
+ logger.error(`Failed to request update manifest for ${this.id}`, e);
+ }
+}
+
+UpdateParser.prototype = {
+ id: null,
+ observer: null,
+ request: null,
+ url: null,
+
+ /**
+ * Called when the manifest has been successfully loaded.
+ */
+ onLoad() {
+ let request = this.request;
+ this.request = null;
+ this._doneAt = new Error("place holder");
+
+ try {
+ lazy.CertUtils.checkCert(
+ request.channel,
+ !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS
+ );
+ } catch (e) {
+ logger.warn("Request failed: " + this.url + " - " + e);
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ if (!Components.isSuccessCode(request.status)) {
+ logger.warn("Request failed: " + this.url + " - " + request.status);
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ let channel = request.channel;
+ if (channel instanceof Ci.nsIHttpChannel && !channel.requestSucceeded) {
+ logger.warn(
+ "Request failed: " +
+ this.url +
+ " - " +
+ channel.responseStatus +
+ ": " +
+ channel.responseStatusText
+ );
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ let results;
+ try {
+ let json = JSON.parse(request.responseText);
+ results = parseJSONManifest(this.id, request, json);
+ } catch (e) {
+ logger.warn(
+ `onUpdateCheckComplete failed to parse update manifest for ${this.id}`,
+ e
+ );
+ this.notifyError(lazy.AddonManager.ERROR_PARSE_ERROR);
+ return;
+ }
+
+ if ("onUpdateCheckComplete" in this.observer) {
+ try {
+ this.observer.onUpdateCheckComplete(results);
+ } catch (e) {
+ logger.warn(
+ `onUpdateCheckComplete notification failed for ${this.id}`,
+ e
+ );
+ }
+ } else {
+ logger.warn(
+ "onUpdateCheckComplete may not properly cancel",
+ new Error("stack marker")
+ );
+ }
+ },
+
+ /**
+ * Called when the request times out
+ */
+ onTimeout() {
+ this.request = null;
+ this._doneAt = new Error("Timed out");
+ logger.warn("Request for " + this.url + " timed out");
+ this.notifyError(lazy.AddonManager.ERROR_TIMEOUT);
+ },
+
+ /**
+ * Called when the manifest failed to load.
+ */
+ onError() {
+ if (!Components.isSuccessCode(this.request.status)) {
+ logger.warn("Request failed: " + this.url + " - " + this.request.status);
+ } else if (this.request.channel instanceof Ci.nsIHttpChannel) {
+ try {
+ if (this.request.channel.requestSucceeded) {
+ logger.warn(
+ "Request failed: " +
+ this.url +
+ " - " +
+ this.request.channel.responseStatus +
+ ": " +
+ this.request.channel.responseStatusText
+ );
+ }
+ } catch (e) {
+ logger.warn("HTTP Request failed for an unknown reason");
+ }
+ } else {
+ logger.warn("Request failed for an unknown reason");
+ }
+
+ this.request = null;
+ this._doneAt = new Error("UP_onError");
+
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ },
+
+ /**
+ * Helper method to notify the observer that an error occurred.
+ */
+ notifyError(aStatus) {
+ if ("onUpdateCheckError" in this.observer) {
+ try {
+ this.observer.onUpdateCheckError(aStatus);
+ } catch (e) {
+ logger.warn("onUpdateCheckError notification failed", e);
+ }
+ }
+ },
+
+ /**
+ * Called to cancel an in-progress update check.
+ */
+ cancel() {
+ if (!this.request) {
+ logger.error("Trying to cancel already-complete request", this._doneAt);
+ return;
+ }
+ this.request.abort();
+ this.request = null;
+ this._doneAt = new Error("UP_cancel");
+ this.notifyError(lazy.AddonManager.ERROR_CANCELLED);
+ },
+};
+
+/**
+ * Tests if an update matches a version of the application or platform
+ *
+ * @param aUpdate
+ * The available update
+ * @param aAppVersion
+ * The application version to use
+ * @param aPlatformVersion
+ * The platform version to use
+ * @param aIgnoreMaxVersion
+ * Ignore maxVersion when testing if an update matches. Optional.
+ * @param aIgnoreStrictCompat
+ * Ignore strictCompatibility when testing if an update matches. Optional.
+ * @return true if the update is compatible with the application/platform
+ */
+function matchesVersions(
+ aUpdate,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+) {
+ if (aUpdate.strictCompatibility && !aIgnoreStrictCompat) {
+ aIgnoreMaxVersion = false;
+ }
+
+ let result = false;
+ for (let app of aUpdate.targetApplications) {
+ if (app.id == Services.appinfo.ID) {
+ return (
+ Services.vc.compare(aAppVersion, app.minVersion) >= 0 &&
+ (aIgnoreMaxVersion ||
+ Services.vc.compare(aAppVersion, app.maxVersion) <= 0)
+ );
+ }
+ if (app.id == TOOLKIT_ID) {
+ result =
+ Services.vc.compare(aPlatformVersion, app.minVersion) >= 0 &&
+ (aIgnoreMaxVersion ||
+ Services.vc.compare(aPlatformVersion, app.maxVersion) <= 0);
+ }
+ }
+ return result;
+}
+
+export var AddonUpdateChecker = {
+ /**
+ * Retrieves the best matching compatibility update for the application from
+ * a list of available update objects.
+ *
+ * @param aUpdates
+ * An array of update objects
+ * @param aVersion
+ * The version of the add-on to get new compatibility information for
+ * @param aIgnoreCompatibility
+ * An optional parameter to get the first compatibility update that
+ * is compatible with any version of the application or toolkit
+ * @param aAppVersion
+ * The version of the application or null to use the current version
+ * @param aPlatformVersion
+ * The version of the platform or null to use the current version
+ * @param aIgnoreMaxVersion
+ * Ignore maxVersion when testing if an update matches. Optional.
+ * @param aIgnoreStrictCompat
+ * Ignore strictCompatibility when testing if an update matches. Optional.
+ * @return an update object if one matches or null if not
+ */
+ getCompatibilityUpdate(
+ aUpdates,
+ aVersion,
+ aIgnoreCompatibility,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ ) {
+ if (!aAppVersion) {
+ aAppVersion = Services.appinfo.version;
+ }
+ if (!aPlatformVersion) {
+ aPlatformVersion = Services.appinfo.platformVersion;
+ }
+
+ for (let update of aUpdates) {
+ if (Services.vc.compare(update.version, aVersion) == 0) {
+ if (aIgnoreCompatibility) {
+ for (let targetApp of update.targetApplications) {
+ let id = targetApp.id;
+ if (id == Services.appinfo.ID || id == TOOLKIT_ID) {
+ return update;
+ }
+ }
+ } else if (
+ matchesVersions(
+ update,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ )
+ ) {
+ return update;
+ }
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Asynchronously returns the newest available update from a list of update objects.
+ *
+ * @param aUpdates
+ * An array of update objects
+ * @param aAddon
+ * The add-on that is being updated.
+ * @param aAppVersion
+ * The version of the application or null to use the current version
+ * @param aPlatformVersion
+ * The version of the platform or null to use the current version
+ * @param aIgnoreMaxVersion
+ * When determining compatible updates, ignore maxVersion. Optional.
+ * @param aIgnoreStrictCompat
+ * When determining compatible updates, ignore strictCompatibility. Optional.
+ * @return an update object if one matches or null if not
+ */
+ async getNewestCompatibleUpdate(
+ aUpdates,
+ aAddon,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ ) {
+ if (!aAppVersion) {
+ aAppVersion = Services.appinfo.version;
+ }
+ if (!aPlatformVersion) {
+ aPlatformVersion = Services.appinfo.platformVersion;
+ }
+
+ let newestVersion = aAddon.version;
+ let newest = null;
+ let blocked = null;
+ let blockedState;
+ for (let update of aUpdates) {
+ if (!update.updateURL) {
+ continue;
+ }
+ if (Services.vc.compare(newestVersion, update.version) >= 0) {
+ // Update older than add-on version or older than previous result.
+ continue;
+ }
+ if (
+ !matchesVersions(
+ update,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ )
+ ) {
+ continue;
+ }
+ let state = await lazy.Blocklist.getAddonBlocklistState(
+ update,
+ aAppVersion,
+ aPlatformVersion
+ );
+ if (state != Ci.nsIBlocklistService.STATE_NOT_BLOCKED) {
+ if (
+ !blocked ||
+ Services.vc.compare(blocked.version, update.version) < 0
+ ) {
+ blocked = update;
+ blockedState = state;
+ }
+ continue;
+ }
+ newest = update;
+ newestVersion = update.version;
+ }
+ if (
+ blocked &&
+ (!newest || Services.vc.compare(blocked.version, newestVersion) >= 0)
+ ) {
+ // If |newest| has a higher version than |blocked|, then the add-on would
+ // not be considered for installation. But if |blocked| would otherwise
+ // be eligible for installation, then report to telemetry that installation
+ // has been blocked because of the blocklist.
+ lazy.Blocklist.recordAddonBlockChangeTelemetry(
+ {
+ id: aAddon.id,
+ version: blocked.version,
+ blocklistState: blockedState,
+ },
+ "addon_update_check"
+ );
+ }
+ return newest;
+ },
+
+ /**
+ * Starts an update check.
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aUrl
+ * The URL of the add-on's update manifest
+ * @param aObserver
+ * An observer to notify of results
+ * @return UpdateParser so that the caller can use UpdateParser.cancel() to shut
+ * down in-progress update requests
+ */
+ checkForUpdates(aId, aUrl, aObserver) {
+ return new UpdateParser(aId, aUrl, aObserver);
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs b/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs
new file mode 100644
index 0000000000..aaac109fc0
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs
@@ -0,0 +1,934 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ GMPInstallManager: "resource://gre/modules/GMPInstallManager.sys.mjs",
+ Log: "resource://gre/modules/Log.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+import {
+ GMPPrefs,
+ GMPUtils,
+ OPEN_H264_ID,
+ WIDEVINE_L1_ID,
+ WIDEVINE_L3_ID,
+} from "resource://gre/modules/GMPUtils.sys.mjs";
+
+const SEC_IN_A_DAY = 24 * 60 * 60;
+// How long to wait after a user enabled EME before attempting to download CDMs.
+const GMP_CHECK_DELAY = 10 * 1000; // milliseconds
+
+const XHTML = "http://www.w3.org/1999/xhtml";
+
+const NS_GRE_DIR = "GreD";
+const CLEARKEY_PLUGIN_ID = "gmp-clearkey";
+const CLEARKEY_VERSION = "0.1";
+
+const FIRST_CONTENT_PROCESS_TOPIC = "ipc:first-content-process-created";
+
+const GMP_LICENSE_INFO = "plugins-gmp-license-info";
+const GMP_PRIVACY_INFO = "plugins-gmp-privacy-info";
+const GMP_LEARN_MORE = "learn_more_label";
+
+const GMP_PLUGINS = [
+ {
+ id: OPEN_H264_ID,
+ name: "plugins-openh264-name",
+ description: "plugins-openh264-description",
+ level: "",
+ libName: "gmpopenh264",
+ // The following licenseURL is part of an awful hack to include the OpenH264
+ // license without having bug 624602 fixed yet, and intentionally ignores
+ // localisation.
+ licenseURL: "chrome://mozapps/content/extensions/OpenH264-license.txt",
+ homepageURL: "https://www.openh264.org/",
+ },
+ {
+ id: WIDEVINE_L1_ID,
+ name: "plugins-widevine-name",
+ description: "plugins-widevine-description",
+ level: "L1",
+ libName: "Google.Widevine.CDM",
+ licenseURL: "https://www.google.com/policies/privacy/",
+ homepageURL: "https://www.widevine.com/",
+ isEME: true,
+ },
+ {
+ id: WIDEVINE_L3_ID,
+ name: "plugins-widevine-name",
+ description: "plugins-widevine-description",
+ level: "L3",
+ libName: "widevinecdm",
+ licenseURL: "https://www.google.com/policies/privacy/",
+ homepageURL: "https://www.widevine.com/",
+ isEME: true,
+ },
+];
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "addonsBundle",
+ () => new Localization(["toolkit/about/aboutAddons.ftl"], true)
+);
+ChromeUtils.defineLazyGetter(lazy, "gmpService", () =>
+ Cc["@mozilla.org/gecko-media-plugin-service;1"].getService(
+ Ci.mozIGeckoMediaPluginChromeService
+ )
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gmpProviderEnabled",
+ GMPPrefs.KEY_PROVIDER_ENABLED
+);
+
+var gLogger;
+var gLogAppenderDump = null;
+
+function configureLogging() {
+ if (!gLogger) {
+ gLogger = lazy.Log.repository.getLogger("Toolkit.GMP");
+ gLogger.addAppender(
+ new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter())
+ );
+ }
+ gLogger.level = GMPPrefs.getInt(
+ GMPPrefs.KEY_LOGGING_LEVEL,
+ lazy.Log.Level.Warn
+ );
+
+ let logDumping = GMPPrefs.getBool(GMPPrefs.KEY_LOGGING_DUMP, false);
+ if (logDumping != !!gLogAppenderDump) {
+ if (logDumping) {
+ gLogAppenderDump = new lazy.Log.DumpAppender(
+ new lazy.Log.BasicFormatter()
+ );
+ gLogger.addAppender(gLogAppenderDump);
+ } else {
+ gLogger.removeAppender(gLogAppenderDump);
+ gLogAppenderDump = null;
+ }
+ }
+}
+
+/**
+ * The GMPWrapper provides the info for the various GMP plugins to public
+ * callers through the API.
+ */
+function GMPWrapper(aPluginInfo, aRawPluginInfo) {
+ this._plugin = aPluginInfo;
+ this._rawPlugin = aRawPluginInfo;
+ this._log = lazy.Log.repository.getLoggerWithMessagePrefix(
+ "Toolkit.GMP",
+ "GMPWrapper(" + this._plugin.id + ") "
+ );
+ Services.prefs.addObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id),
+ this,
+ true
+ );
+ Services.prefs.addObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id),
+ this,
+ true
+ );
+ if (this._plugin.isEME) {
+ Services.prefs.addObserver(GMPPrefs.KEY_EME_ENABLED, this, true);
+ Services.obs.addObserver(this, "EMEVideo:CDMMissing");
+ }
+}
+
+GMPWrapper.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ // An active task that checks for plugin updates and installs them.
+ _updateTask: null,
+ _gmpPath: null,
+ _isUpdateCheckPending: false,
+
+ set gmpPath(aPath) {
+ this._gmpPath = aPath;
+ },
+ get gmpPath() {
+ if (!this._gmpPath && this.isInstalled) {
+ this._gmpPath = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this._plugin.id,
+ GMPPrefs.getString(GMPPrefs.KEY_PLUGIN_VERSION, null, this._plugin.id)
+ );
+ }
+ return this._gmpPath;
+ },
+
+ get id() {
+ return this._plugin.id;
+ },
+ get libName() {
+ return this._plugin.libName;
+ },
+ get type() {
+ return "plugin";
+ },
+ get isGMPlugin() {
+ return true;
+ },
+ get name() {
+ return this._plugin.name;
+ },
+ get creator() {
+ return null;
+ },
+ get homepageURL() {
+ return this._plugin.homepageURL;
+ },
+
+ get description() {
+ return this._plugin.description;
+ },
+ get fullDescription() {
+ return null;
+ },
+
+ getFullDescription(doc) {
+ let plugin = this._rawPlugin;
+
+ let frag = doc.createDocumentFragment();
+ for (let [urlProp, labelId] of [
+ ["learnMoreURL", GMP_LEARN_MORE],
+ [
+ "licenseURL",
+ this.id == WIDEVINE_L1_ID || this.id == WIDEVINE_L3_ID
+ ? GMP_PRIVACY_INFO
+ : GMP_LICENSE_INFO,
+ ],
+ ]) {
+ if (plugin[urlProp]) {
+ let a = doc.createElementNS(XHTML, "a");
+ a.href = plugin[urlProp];
+ a.target = "_blank";
+ a.textContent = lazy.addonsBundle.formatValueSync(labelId);
+
+ if (frag.childElementCount) {
+ frag.append(
+ doc.createElementNS(XHTML, "br"),
+ doc.createElementNS(XHTML, "br")
+ );
+ }
+ frag.append(a);
+ }
+ }
+
+ return frag;
+ },
+
+ get version() {
+ return GMPPrefs.getString(
+ GMPPrefs.KEY_PLUGIN_VERSION,
+ null,
+ this._plugin.id
+ );
+ },
+
+ get isActive() {
+ return (
+ !this.appDisabled &&
+ !this.userDisabled &&
+ !GMPUtils.isPluginHidden(this._plugin)
+ );
+ },
+ get appDisabled() {
+ if (
+ this._plugin.isEME &&
+ !GMPPrefs.getBool(GMPPrefs.KEY_EME_ENABLED, true)
+ ) {
+ // If "media.eme.enabled" is false, all EME plugins are disabled.
+ return true;
+ }
+ return false;
+ },
+
+ get userDisabled() {
+ return !GMPPrefs.getBool(
+ GMPPrefs.KEY_PLUGIN_ENABLED,
+ true,
+ this._plugin.id
+ );
+ },
+ set userDisabled(aVal) {
+ GMPPrefs.setBool(
+ GMPPrefs.KEY_PLUGIN_ENABLED,
+ aVal === false,
+ this._plugin.id
+ );
+ },
+
+ async enable() {
+ this.userDisabled = false;
+ },
+ async disable() {
+ this.userDisabled = true;
+ },
+
+ get blocklistState() {
+ return Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ },
+ get size() {
+ return 0;
+ },
+ get scope() {
+ return lazy.AddonManager.SCOPE_APPLICATION;
+ },
+ get pendingOperations() {
+ return lazy.AddonManager.PENDING_NONE;
+ },
+
+ get operationsRequiringRestart() {
+ return lazy.AddonManager.OP_NEEDS_RESTART_NONE;
+ },
+
+ get permissions() {
+ let permissions = 0;
+ if (!this.appDisabled) {
+ permissions |= lazy.AddonManager.PERM_CAN_UPGRADE;
+ permissions |= this.userDisabled
+ ? lazy.AddonManager.PERM_CAN_ENABLE
+ : lazy.AddonManager.PERM_CAN_DISABLE;
+ }
+ return permissions;
+ },
+
+ get updateDate() {
+ let time = Number(
+ GMPPrefs.getInt(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, 0, this._plugin.id)
+ );
+ if (this.isInstalled) {
+ return new Date(time * 1000);
+ }
+ return null;
+ },
+
+ get isCompatible() {
+ return true;
+ },
+
+ get isPlatformCompatible() {
+ return true;
+ },
+
+ get providesUpdatesSecurely() {
+ return true;
+ },
+
+ get foreignInstall() {
+ return false;
+ },
+
+ get installTelemetryInfo() {
+ return { source: "gmp-plugin" };
+ },
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ return true;
+ },
+
+ get applyBackgroundUpdates() {
+ if (!GMPPrefs.isSet(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, this._plugin.id)) {
+ return lazy.AddonManager.AUTOUPDATE_DEFAULT;
+ }
+
+ return GMPPrefs.getBool(
+ GMPPrefs.KEY_PLUGIN_AUTOUPDATE,
+ true,
+ this._plugin.id
+ )
+ ? lazy.AddonManager.AUTOUPDATE_ENABLE
+ : lazy.AddonManager.AUTOUPDATE_DISABLE;
+ },
+
+ set applyBackgroundUpdates(aVal) {
+ if (aVal == lazy.AddonManager.AUTOUPDATE_DEFAULT) {
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, this._plugin.id);
+ } else if (aVal == lazy.AddonManager.AUTOUPDATE_ENABLE) {
+ GMPPrefs.setBool(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, true, this._plugin.id);
+ } else if (aVal == lazy.AddonManager.AUTOUPDATE_DISABLE) {
+ GMPPrefs.setBool(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, false, this._plugin.id);
+ }
+ },
+
+ /**
+ * Called by the addon manager to update GMP addons. For example this will be
+ * used if a user manually checks for GMP plugin updates by using the
+ * menu in about:addons.
+ *
+ * This function is not used if MediaKeySystemAccess is requested and
+ * Widevine is not yet installed, or if the user toggles prefs to enable EME.
+ * For the function used in those cases see `checkForUpdates`.
+ */
+ findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) {
+ this._log.trace(
+ "findUpdates() - " + this._plugin.id + " - reason=" + aReason
+ );
+
+ // In the case of GMP addons we do not wish to implement AddonInstall, as
+ // we don't want to display information as in a normal addon install such
+ // as a download progress bar. As such, we short circuit our
+ // listeners by indicating that no updates exist (though some may).
+ lazy.AddonManagerPrivate.callNoUpdateListeners(this, aListener);
+
+ if (aReason === lazy.AddonManager.UPDATE_WHEN_PERIODIC_UPDATE) {
+ if (!lazy.AddonManager.shouldAutoUpdate(this)) {
+ this._log.trace(
+ "findUpdates() - " + this._plugin.id + " - no autoupdate"
+ );
+ return Promise.resolve(false);
+ }
+
+ let secSinceLastCheck =
+ Date.now() / 1000 -
+ Services.prefs.getIntPref(GMPPrefs.KEY_UPDATE_LAST_CHECK, 0);
+ if (secSinceLastCheck <= SEC_IN_A_DAY) {
+ this._log.trace(
+ "findUpdates() - " +
+ this._plugin.id +
+ " - last check was less then a day ago"
+ );
+ return Promise.resolve(false);
+ }
+ } else if (aReason !== lazy.AddonManager.UPDATE_WHEN_USER_REQUESTED) {
+ this._log.trace(
+ "findUpdates() - " +
+ this._plugin.id +
+ " - the given reason to update is not supported"
+ );
+ return Promise.resolve(false);
+ }
+
+ if (this._updateTask !== null) {
+ this._log.trace(
+ "findUpdates() - " + this._plugin.id + " - update task already running"
+ );
+ return this._updateTask;
+ }
+
+ this._updateTask = (async () => {
+ this._log.trace("findUpdates() - updateTask");
+ try {
+ let installManager = new lazy.GMPInstallManager();
+ let res = await installManager.checkForAddons();
+ let update = res.addons.find(addon => addon.id === this._plugin.id);
+ if (update && update.isValid && !update.isInstalled) {
+ this._log.trace(
+ "findUpdates() - found update for " +
+ this._plugin.id +
+ ", installing"
+ );
+ await installManager.installAddon(update);
+ } else {
+ this._log.trace("findUpdates() - no updates for " + this._plugin.id);
+ }
+ this._log.info(
+ "findUpdates() - updateTask succeeded for " + this._plugin.id
+ );
+ } catch (e) {
+ this._log.error(
+ "findUpdates() - updateTask for " + this._plugin.id + " threw",
+ e
+ );
+ throw e;
+ } finally {
+ this._updateTask = null;
+ }
+ return true;
+ })();
+
+ return this._updateTask;
+ },
+
+ get pluginLibraries() {
+ if (this.isInstalled) {
+ let path = this.version;
+ return [path];
+ }
+ return [];
+ },
+ get pluginFullpath() {
+ if (this.isInstalled) {
+ let path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this._plugin.id,
+ this.version
+ );
+ return [path];
+ }
+ return [];
+ },
+
+ get isInstalled() {
+ return this.version && !!this.version.length;
+ },
+
+ _handleEnabledChanged() {
+ this._log.info(
+ "_handleEnabledChanged() id=" +
+ this._plugin.id +
+ " isActive=" +
+ this.isActive
+ );
+
+ lazy.AddonManagerPrivate.callAddonListeners(
+ this.isActive ? "onEnabling" : "onDisabling",
+ this,
+ false
+ );
+ if (this._gmpPath) {
+ if (this.isActive) {
+ this._log.info(
+ "onPrefEnabledChanged() - adding gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.addPluginDirectory(this._gmpPath);
+ } else {
+ this._log.info(
+ "onPrefEnabledChanged() - removing gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.removePluginDirectory(this._gmpPath);
+ }
+ }
+ lazy.AddonManagerPrivate.callAddonListeners(
+ this.isActive ? "onEnabled" : "onDisabled",
+ this
+ );
+ },
+
+ onPrefEMEGlobalEnabledChanged() {
+ this._log.info(
+ "onPrefEMEGlobalEnabledChanged() id=" +
+ this._plugin.id +
+ " appDisabled=" +
+ this.appDisabled +
+ " isActive=" +
+ this.isActive +
+ " hidden=" +
+ GMPUtils.isPluginHidden(this._plugin)
+ );
+
+ lazy.AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
+ "appDisabled",
+ ]);
+ // If EME or the GMP itself are disabled, uninstall the GMP.
+ // Otherwise, check for updates, so we download and install the GMP.
+ if (this.appDisabled) {
+ this.uninstallPlugin();
+ } else if (!GMPUtils.isPluginHidden(this._plugin)) {
+ lazy.AddonManagerPrivate.callInstallListeners(
+ "onExternalInstall",
+ null,
+ this,
+ null,
+ false
+ );
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this, false);
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this);
+ this.checkForUpdates(GMP_CHECK_DELAY);
+ }
+ if (!this.userDisabled) {
+ this._handleEnabledChanged();
+ }
+ },
+
+ /**
+ * This is called if prefs are changed to enable EME, or if Widevine
+ * MediaKeySystemAccess is requested but the Widevine CDM is not installed.
+ *
+ * For the function used by the addon manager see `findUpdates`.
+ */
+ checkForUpdates(delay) {
+ if (this._isUpdateCheckPending) {
+ return;
+ }
+ this._isUpdateCheckPending = true;
+ GMPPrefs.reset(GMPPrefs.KEY_UPDATE_LAST_CHECK, null);
+ // Delay this in case the user changes his mind and doesn't want to
+ // enable EME after all.
+ lazy.setTimeout(() => {
+ if (!this.appDisabled) {
+ let gmpInstallManager = new lazy.GMPInstallManager();
+ // We don't really care about the results, if someone is interested
+ // they can check the log.
+ gmpInstallManager.simpleCheckAndInstall().catch(() => {});
+ }
+ this._isUpdateCheckPending = false;
+ }, delay);
+ },
+
+ onPrefEnabledChanged() {
+ if (!this._plugin.isEME || !this.appDisabled) {
+ this._handleEnabledChanged();
+ }
+ },
+
+ onPrefVersionChanged() {
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalling", this, false);
+ if (this._gmpPath) {
+ this._log.info(
+ "onPrefVersionChanged() - unregistering gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.removeAndDeletePluginDirectory(
+ this._gmpPath,
+ true /* can defer */
+ );
+ }
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+
+ lazy.AddonManagerPrivate.callInstallListeners(
+ "onExternalInstall",
+ null,
+ this,
+ null,
+ false
+ );
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this, false);
+ this._gmpPath = null;
+ if (this.isInstalled) {
+ this._gmpPath = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this._plugin.id,
+ GMPPrefs.getString(GMPPrefs.KEY_PLUGIN_VERSION, null, this._plugin.id)
+ );
+ }
+ if (this._gmpPath && this.isActive) {
+ this._log.info(
+ "onPrefVersionChanged() - registering gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.addPluginDirectory(this._gmpPath);
+ }
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this);
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ let pref = data;
+ if (
+ pref ==
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id)
+ ) {
+ this.onPrefEnabledChanged();
+ } else if (
+ pref ==
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id)
+ ) {
+ this.onPrefVersionChanged();
+ } else if (pref == GMPPrefs.KEY_EME_ENABLED) {
+ this.onPrefEMEGlobalEnabledChanged();
+ }
+ } else if (topic == "EMEVideo:CDMMissing") {
+ this.checkForUpdates(0);
+ }
+ },
+
+ uninstallPlugin() {
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalling", this, false);
+ if (this.gmpPath) {
+ this._log.info(
+ "uninstallPlugin() - unregistering gmp directory " + this.gmpPath
+ );
+ lazy.gmpService.removeAndDeletePluginDirectory(this.gmpPath);
+ }
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_VERSION, this.id);
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_HASHVALUE, this.id);
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_ABI, this.id);
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, this.id);
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+ },
+
+ shutdown() {
+ Services.prefs.removeObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id),
+ this
+ );
+ Services.prefs.removeObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id),
+ this
+ );
+ if (this._plugin.isEME) {
+ Services.prefs.removeObserver(GMPPrefs.KEY_EME_ENABLED, this);
+ Services.obs.removeObserver(this, "EMEVideo:CDMMissing");
+ }
+ return this._updateTask;
+ },
+
+ _arePluginFilesOnDisk() {
+ let fileExists = function (aGmpPath, aFileName) {
+ let f = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ let path = PathUtils.join(aGmpPath, aFileName);
+ f.initWithPath(path);
+ return f.exists();
+ };
+
+ let libName =
+ AppConstants.DLL_PREFIX + this._plugin.libName + AppConstants.DLL_SUFFIX;
+ let infoName;
+ if (
+ this._plugin.id == WIDEVINE_L1_ID ||
+ this._plugin.id == WIDEVINE_L3_ID
+ ) {
+ infoName = "manifest.json";
+ } else {
+ infoName = this._plugin.id.substring(4) + ".info";
+ }
+
+ return (
+ fileExists(this.gmpPath, libName) && fileExists(this.gmpPath, infoName)
+ );
+ },
+
+ validate() {
+ if (!this.isInstalled) {
+ // Not installed -> Valid.
+ return {
+ installed: false,
+ valid: true,
+ };
+ }
+
+ let expectedABI = GMPUtils._expectedABI(this._plugin);
+ let abi = GMPPrefs.getString(
+ GMPPrefs.KEY_PLUGIN_ABI,
+ expectedABI,
+ this._plugin.id
+ );
+ if (abi != expectedABI) {
+ // ABI doesn't match. Possibly this is a profile migrated across platforms
+ // or from 32 -> 64 bit.
+ return {
+ installed: true,
+ mismatchedABI: true,
+ valid: false,
+ };
+ }
+
+ // Installed -> Check if files are missing.
+ let filesOnDisk = this._arePluginFilesOnDisk();
+ return {
+ installed: true,
+ valid: filesOnDisk,
+ };
+ },
+};
+
+var GMPProvider = {
+ get name() {
+ return "GMPProvider";
+ },
+
+ _plugins: null,
+
+ startup() {
+ configureLogging();
+ this._log = lazy.Log.repository.getLoggerWithMessagePrefix(
+ "Toolkit.GMP",
+ "GMPProvider."
+ );
+ this.buildPluginList();
+ this.ensureProperCDMInstallState();
+
+ Services.prefs.addObserver(GMPPrefs.KEY_LOG_BASE, configureLogging);
+
+ for (let plugin of this._plugins.values()) {
+ let wrapper = plugin.wrapper;
+ let gmpPath = wrapper.gmpPath;
+ let isEnabled = wrapper.isActive;
+ this._log.trace(
+ "startup - enabled=" + isEnabled + ", gmpPath=" + gmpPath
+ );
+
+ if (gmpPath && isEnabled) {
+ let validation = wrapper.validate();
+ if (validation.mismatchedABI) {
+ this._log.info(
+ "startup - gmp " + plugin.id + " mismatched ABI, uninstalling"
+ );
+ wrapper.uninstallPlugin();
+ continue;
+ }
+ if (!validation.valid) {
+ this._log.info(
+ "startup - gmp " + plugin.id + " invalid, uninstalling"
+ );
+ wrapper.uninstallPlugin();
+ continue;
+ }
+ this._log.info("startup - adding gmp directory " + gmpPath);
+ try {
+ lazy.gmpService.addPluginDirectory(gmpPath);
+ } catch (e) {
+ if (e.name != "NS_ERROR_NOT_AVAILABLE") {
+ throw e;
+ }
+ this._log.warn(
+ "startup - adding gmp directory failed with " +
+ e.name +
+ " - sandboxing not available?",
+ e
+ );
+ }
+ }
+ }
+
+ try {
+ let greDir = Services.dirsvc.get(NS_GRE_DIR, Ci.nsIFile);
+ let path = greDir.path;
+ if (
+ GMPUtils._isWindowsOnARM64() &&
+ GMPPrefs.getBool(
+ GMPPrefs.KEY_PLUGIN_ALLOW_X64_ON_ARM64,
+ true,
+ CLEARKEY_PLUGIN_ID
+ )
+ ) {
+ path = PathUtils.join(path, "i686");
+ }
+ let clearkeyPath = PathUtils.join(
+ path,
+ CLEARKEY_PLUGIN_ID,
+ CLEARKEY_VERSION
+ );
+ this._log.info("startup - adding clearkey CDM directory " + clearkeyPath);
+ lazy.gmpService.addPluginDirectory(clearkeyPath);
+ } catch (e) {
+ this._log.warn("startup - adding clearkey CDM failed", e);
+ }
+ },
+
+ shutdown() {
+ this._log.trace("shutdown");
+ Services.prefs.removeObserver(GMPPrefs.KEY_LOG_BASE, configureLogging);
+
+ let shutdownTask = (async () => {
+ this._log.trace("shutdown - shutdownTask");
+ let shutdownSucceeded = true;
+
+ for (let plugin of this._plugins.values()) {
+ try {
+ await plugin.wrapper.shutdown();
+ } catch (e) {
+ shutdownSucceeded = false;
+ }
+ }
+
+ this._plugins = null;
+
+ if (!shutdownSucceeded) {
+ throw new Error("Shutdown failed");
+ }
+ })();
+
+ return shutdownTask;
+ },
+
+ async getAddonByID(aId) {
+ if (!this.isEnabled) {
+ return null;
+ }
+
+ let plugin = this._plugins.get(aId);
+ if (plugin && !GMPUtils.isPluginHidden(plugin)) {
+ return plugin.wrapper;
+ }
+ return null;
+ },
+
+ async getAddonsByTypes(aTypes) {
+ if (!this.isEnabled || (aTypes && !aTypes.includes("plugin"))) {
+ return [];
+ }
+
+ let results = Array.from(this._plugins.values())
+ .filter(p => !GMPUtils.isPluginHidden(p))
+ .map(p => p.wrapper);
+
+ return results;
+ },
+
+ get isEnabled() {
+ return lazy.gmpProviderEnabled;
+ },
+
+ buildPluginList() {
+ this._plugins = new Map();
+ for (let aPlugin of GMP_PLUGINS) {
+ let plugin = {
+ id: aPlugin.id,
+ name: lazy.addonsBundle.formatValueSync(aPlugin.name),
+ description: lazy.addonsBundle.formatValueSync(aPlugin.description),
+ libName: aPlugin.libName,
+ homepageURL: aPlugin.homepageURL,
+ optionsURL: aPlugin.optionsURL,
+ wrapper: null,
+ isEME: aPlugin.isEME,
+ };
+ plugin.wrapper = new GMPWrapper(plugin, aPlugin);
+ this._plugins.set(plugin.id, plugin);
+ }
+ },
+
+ ensureProperCDMInstallState() {
+ if (!GMPPrefs.getBool(GMPPrefs.KEY_EME_ENABLED, true)) {
+ for (let plugin of this._plugins.values()) {
+ if (plugin.isEME && plugin.wrapper.isInstalled) {
+ lazy.gmpService.addPluginDirectory(plugin.wrapper.gmpPath);
+ plugin.wrapper.uninstallPlugin();
+ }
+ }
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (topic == FIRST_CONTENT_PROCESS_TOPIC) {
+ lazy.AddonManagerPrivate.registerProvider(GMPProvider, ["plugin"]);
+ Services.obs.notifyObservers(null, "gmp-provider-registered");
+
+ Services.obs.removeObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+ }
+ },
+
+ addObserver() {
+ Services.obs.addObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+ },
+};
+
+GMPProvider.addObserver();
+
+// For test use only.
+export const GMPTestUtils = {
+ /**
+ * Used to override the GMP service with a mock.
+ *
+ * @param {object} mockService
+ * The mocked gmpService object.
+ * @param {function} callback
+ * Method called with the overridden gmpService. The override
+ * is undone after the callback returns.
+ */
+ async overrideGmpService(mockService, callback) {
+ let originalGmpService = lazy.gmpService;
+ lazy.gmpService = mockService;
+ try {
+ return await callback();
+ } finally {
+ lazy.gmpService = originalGmpService;
+ }
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs b/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs
new file mode 100644
index 0000000000..1615a551c8
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs
@@ -0,0 +1,601 @@
+/* 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/. */
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+import { CertUtils } from "resource://gre/modules/CertUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
+});
+
+// This will inherit settings from the "addons" logger.
+var logger = Log.repository.getLogger("addons.productaddons");
+// We want to set the level of this logger independent from its parent to help
+// debug things like GMP updates. Link this to its own level pref.
+logger.manageLevelFromPref("extensions.logging.productaddons.level");
+
+/**
+ * Number of milliseconds after which we need to cancel `downloadXMLWithRequest`
+ * and `conservativeFetch`.
+ *
+ * Bug 1087674 suggests that the XHR/ServiceRequest we use in
+ * `downloadXMLWithRequest` may never terminate in presence of network nuisances
+ * (e.g. strange antivirus behavior). This timeout is a defensive measure to
+ * ensure that we fail cleanly in such case.
+ */
+const TIMEOUT_DELAY_MS = 20000;
+
+/**
+ * Gets the status of an XMLHttpRequest either directly or from its underlying
+ * channel.
+ *
+ * @param request
+ * The XMLHttpRequest.
+ * @returns {Object} result - An object containing the results.
+ * @returns {integer} result.status - Request status code, if available, else the channel nsresult.
+ * @returns {integer} result.channelStatus - Channel nsresult.
+ * @returns {integer} result.errorCode - Request error code.
+ */
+function getRequestStatus(request) {
+ let status = null;
+ let errorCode = null;
+ let channelStatus = null;
+
+ try {
+ status = request.status;
+ } catch (e) {}
+ try {
+ errorCode = request.errorCode;
+ } catch (e) {}
+ try {
+ channelStatus = request.channel.QueryInterface(Ci.nsIRequest).status;
+ } catch (e) {}
+
+ if (status == null) {
+ status = channelStatus;
+ }
+
+ return { status, channelStatus, errorCode };
+}
+
+/**
+ * A wrapper around `ServiceRequest` that behaves like a limited `fetch()`.
+ * This doesn't handle headers like fetch, but can be expanded as callers need.
+ *
+ * Use this in order to leverage the `beConservative` flag, for
+ * example to avoid using HTTP3 to fetch critical data.
+ *
+ * @param input a resource
+ * @returns a Response object
+ */
+async function conservativeFetch(input) {
+ return new Promise(function (resolve, reject) {
+ const request = new lazy.ServiceRequest({ mozAnon: true });
+ request.timeout = TIMEOUT_DELAY_MS;
+
+ request.onerror = () => {
+ let err = new TypeError("NetworkError: Network request failed");
+ err.addonCheckerErr = ProductAddonChecker.NETWORK_REQUEST_ERR;
+ reject(err);
+ };
+ request.ontimeout = () => {
+ let err = new TypeError("Timeout: Network request failed");
+ err.addonCheckerErr = ProductAddonChecker.NETWORK_TIMEOUT_ERR;
+ reject(err);
+ };
+ request.onabort = () => {
+ let err = new DOMException("Aborted", "AbortError");
+ err.addonCheckerErr = ProductAddonChecker.ABORT_ERR;
+ reject(err);
+ };
+ request.onload = () => {
+ const responseAttributes = {
+ status: request.status,
+ statusText: request.statusText,
+ url: request.responseURL,
+ };
+ resolve(new Response(request.response, responseAttributes));
+ };
+
+ const method = "GET";
+
+ request.open(method, input, true);
+
+ request.send();
+ });
+}
+
+/**
+ * Verifies the content signature on GMP's update.xml. When we fetch update.xml
+ * balrog should send back content signature headers, which this function
+ * is used to verify.
+ *
+ * @param data
+ * The data received from balrog. I.e. the xml contents of update.xml.
+ * @param contentSignatureHeader
+ * The contents of the 'content-signature' header received along with
+ * `data`.
+ * @return A promise that will resolve to nothing if the signature verification
+ * succeeds, or rejects on failure, with an Error that sets its
+ * addonCheckerErr property disambiguate failure cases and a message
+ * explaining the error.
+ */
+async function verifyGmpContentSignature(data, contentSignatureHeader) {
+ if (!contentSignatureHeader) {
+ logger.warn(
+ "Unexpected missing content signature header during content signature validation"
+ );
+ let err = new Error(
+ "Content signature validation failed: missing content signature header"
+ );
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+ // Split out the header. It should contain a the following fields, separated by a semicolon
+ // - x5u - a URI to the cert chain. See also https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.5
+ // - p384ecdsa - the signature to verify. See also https://github.com/mozilla-services/autograph/blob/main/signer/contentsignaturepki/README.md
+ const headerFields = contentSignatureHeader
+ .split(";") // Split fields...
+ .map(s => s.trim()) // Remove whitespace...
+ .map(s => [
+ // Break each field into it's name and value. This more verbose version is
+ // used instead of `split()` to handle values that contain = characters. This
+ // shouldn't happen for the signature because it's base64_url (no = padding),
+ // but it's not clear if it's possible for the x5u URL (as part of a query).
+ // Guard anyway, better safe than sorry.
+ s.substring(0, s.indexOf("=")), // Get field name...
+ s.substring(s.indexOf("=") + 1), // and field value.
+ ]);
+
+ let x5u;
+ let signature;
+ for (const [fieldName, fieldValue] of headerFields) {
+ if (fieldName == "x5u") {
+ x5u = fieldValue;
+ } else if (fieldName == "p384ecdsa") {
+ // The signature needs to contain 'p384ecdsa', so stich it back together.
+ signature = `p384ecdsa=${fieldValue}`;
+ }
+ }
+
+ if (!x5u) {
+ logger.warn("Unexpected missing x5u during content signature validation");
+ let err = Error("Content signature validation failed: missing x5u");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+
+ if (!signature) {
+ logger.warn(
+ "Unexpected missing signature during content signature validation"
+ );
+ let err = Error("Content signature validation failed: missing signature");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+
+ // The x5u field should contain the location of the cert chain, fetch it.
+ // Use `conservativeFetch` so we get conservative behaviour and ensure (more)
+ // reliable fetching.
+ const certChain = await (await conservativeFetch(x5u)).text();
+
+ const verifier = Cc[
+ "@mozilla.org/security/contentsignatureverifier;1"
+ ].createInstance(Ci.nsIContentSignatureVerifier);
+
+ // See bug 1771992. In the future, this may need to handle staging and dev
+ // environments in addition to just production and testing.
+ let root = Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot;
+ if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+ root = Ci.nsIX509CertDB.AppXPCShellRoot;
+ }
+
+ let valid;
+ try {
+ valid = await verifier.asyncVerifyContentSignature(
+ data,
+ signature,
+ certChain,
+ "aus.content-signature.mozilla.org",
+ root
+ );
+ } catch (err) {
+ logger.warn(`Unexpected error while validating content signature: ${err}`);
+ let newErr = new Error(`Content signature validation failed: ${err}`);
+ newErr.addonCheckerErr = ProductAddonChecker.VERIFICATION_FAILED_ERR;
+ throw newErr;
+ }
+
+ if (!valid) {
+ logger.warn("Unexpected invalid content signature found during validation");
+ let err = new Error("Content signature is not valid");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_INVALID_ERR;
+ throw err;
+ }
+}
+
+/**
+ * Downloads an XML document from a URL optionally testing the SSL certificate
+ * for certain attributes.
+ *
+ * @param url
+ * The url to download from.
+ * @param allowNonBuiltIn
+ * Whether to trust SSL certificates without a built-in CA issuer.
+ * @param allowedCerts
+ * The list of certificate attributes to match the SSL certificate
+ * against or null to skip checks.
+ * @return a promise that resolves to the ServiceRequest request on success or
+ * rejects with a JS exception in case of error.
+ */
+function downloadXMLWithRequest(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null
+) {
+ return new Promise((resolve, reject) => {
+ let request = new lazy.ServiceRequest();
+ // This is here to let unit test code override the ServiceRequest.
+ if (request.wrappedJSObject) {
+ request = request.wrappedJSObject;
+ }
+ request.open("GET", url, true);
+ request.channel.notificationCallbacks = new CertUtils.BadCertHandler(
+ allowNonBuiltIn
+ );
+ // Prevent the request from reading from the cache.
+ request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ // Prevent the request from writing to the cache.
+ request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
+ // Don't send any cookies
+ request.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS;
+ request.timeout = TIMEOUT_DELAY_MS;
+
+ request.overrideMimeType("text/xml");
+ // The Cache-Control header is only interpreted by proxies and the
+ // final destination. It does not help if a resource is already
+ // cached locally.
+ request.setRequestHeader("Cache-Control", "no-cache");
+ // HTTP/1.0 servers might not implement Cache-Control and
+ // might only implement Pragma: no-cache
+ request.setRequestHeader("Pragma", "no-cache");
+
+ let fail = event => {
+ let request = event.target;
+ let status = getRequestStatus(request);
+ let message =
+ "Failed downloading XML, status: " +
+ status.status +
+ ", channelStatus: " +
+ status.channelStatus +
+ ", errorCode: " +
+ status.errorCode +
+ ", reason: " +
+ event.type;
+ logger.warn(message);
+ let ex = new Error(message);
+ ex.status = status.status;
+ if (event.type == "error") {
+ ex.addonCheckerErr = ProductAddonChecker.NETWORK_REQUEST_ERR;
+ } else if (event.type == "abort") {
+ ex.addonCheckerErr = ProductAddonChecker.ABORT_ERR;
+ } else if (event.type == "timeout") {
+ ex.addonCheckerErr = ProductAddonChecker.NETWORK_TIMEOUT_ERR;
+ }
+ reject(ex);
+ };
+
+ let success = event => {
+ logger.info("Completed downloading document");
+ let request = event.target;
+
+ try {
+ CertUtils.checkCert(request.channel, allowNonBuiltIn, allowedCerts);
+ } catch (ex) {
+ logger.error("Request failed certificate checks: " + ex);
+ ex.status = getRequestStatus(request).requestStatus;
+ ex.addonCheckerErr = ProductAddonChecker.VERIFICATION_FAILED_ERR;
+ reject(ex);
+ return;
+ }
+
+ resolve(request);
+ };
+
+ request.addEventListener("error", fail);
+ request.addEventListener("abort", fail);
+ request.addEventListener("timeout", fail);
+ request.addEventListener("load", success);
+
+ logger.info("sending request to: " + url);
+ request.send(null);
+ });
+}
+
+/**
+ * Downloads an XML document from a URL optionally testing the SSL certificate
+ * for certain attributes, and/or testing the content signature.
+ *
+ * @param url
+ * The url to download from.
+ * @param allowNonBuiltIn
+ * Whether to trust SSL certificates without a built-in CA issuer.
+ * @param allowedCerts
+ * The list of certificate attributes to match the SSL certificate
+ * against or null to skip checks.
+ * @param verifyContentSignature
+ * When true, will verify the content signature information from the
+ * response header. Failure to verify will result in an error.
+ * @return a promise that resolves to the DOM document downloaded or rejects
+ * with a JS exception in case of error.
+ */
+async function downloadXML(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null,
+ verifyContentSignature = false
+) {
+ let request = await downloadXMLWithRequest(
+ url,
+ allowNonBuiltIn,
+ allowedCerts
+ );
+ if (verifyContentSignature) {
+ await verifyGmpContentSignature(
+ request.response,
+ request.getResponseHeader("content-signature")
+ );
+ }
+ return request.responseXML;
+}
+
+/**
+ * Parses a list of add-ons from a DOM document.
+ *
+ * @param document
+ * The DOM document to parse.
+ * @return null if there is no <addons> element otherwise an object containing
+ * an array of the addons listed and a field notifying whether the
+ * fallback was used.
+ */
+function parseXML(document) {
+ // Check that the root element is correct
+ if (document.documentElement.localName != "updates") {
+ let err = new Error(
+ "got node name: " +
+ document.documentElement.localName +
+ ", expected: updates"
+ );
+ err.addonCheckerErr = ProductAddonChecker.XML_PARSE_ERR;
+ throw err;
+ }
+
+ // Check if there are any addons elements in the updates element
+ let addons = document.querySelector("updates:root > addons");
+ if (!addons) {
+ return null;
+ }
+
+ let results = [];
+ let addonList = document.querySelectorAll("updates:root > addons > addon");
+ for (let addonElement of addonList) {
+ let addon = {};
+
+ for (let name of [
+ "id",
+ "URL",
+ "hashFunction",
+ "hashValue",
+ "version",
+ "size",
+ ]) {
+ if (addonElement.hasAttribute(name)) {
+ addon[name] = addonElement.getAttribute(name);
+ }
+ }
+ addon.size = Number(addon.size) || undefined;
+
+ results.push(addon);
+ }
+
+ return {
+ usedFallback: false,
+ addons: results,
+ };
+}
+
+/**
+ * Downloads file from a URL using ServiceRequest.
+ *
+ * @param url
+ * The url to download from.
+ * @param options (optional)
+ * @param options.httpsOnlyNoUpgrade
+ * Prevents upgrade to https:// when HTTPS-Only Mode is enabled.
+ * @return a promise that resolves to the path of a temporary file or rejects
+ * with a JS exception in case of error.
+ */
+function downloadFile(url, options = { httpsOnlyNoUpgrade: false }) {
+ return new Promise((resolve, reject) => {
+ let sr = new lazy.ServiceRequest();
+
+ sr.onload = function (response) {
+ logger.info("downloadFile File download. status=" + sr.status);
+ if (sr.status != 200 && sr.status != 206) {
+ reject(Components.Exception("File download failed", sr.status));
+ return;
+ }
+ (async function () {
+ const path = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ "tmpaddon"
+ );
+ logger.info(`Downloaded file will be saved to ${path}`);
+ await IOUtils.write(path, new Uint8Array(sr.response));
+
+ return path;
+ })().then(resolve, reject);
+ };
+
+ let fail = event => {
+ let request = event.target;
+ let status = getRequestStatus(request);
+ let message =
+ "Failed downloading via ServiceRequest, status: " +
+ status.status +
+ ", channelStatus: " +
+ status.channelStatus +
+ ", errorCode: " +
+ status.errorCode +
+ ", reason: " +
+ event.type;
+ logger.warn(message);
+ let ex = new Error(message);
+ ex.status = status.status;
+ reject(ex);
+ };
+ sr.addEventListener("error", fail);
+ sr.addEventListener("abort", fail);
+
+ sr.responseType = "arraybuffer";
+ try {
+ sr.open("GET", url);
+ if (options.httpsOnlyNoUpgrade) {
+ sr.channel.loadInfo.httpsOnlyStatus |= Ci.nsILoadInfo.HTTPS_ONLY_EXEMPT;
+ }
+ // Allow deprecated HTTP request from SystemPrincipal
+ sr.channel.loadInfo.allowDeprecatedSystemRequests = true;
+ sr.send(null);
+ } catch (ex) {
+ reject(ex);
+ }
+ });
+}
+
+/**
+ * Verifies that a downloaded file matches what was expected.
+ *
+ * @param properties
+ * The properties to check, `hashFunction` with `hashValue`
+ * are supported. Any properties missing won't be checked.
+ * @param path
+ * The path of the file to check.
+ * @return a promise that resolves if the file matched or rejects with a JS
+ * exception in case of error.
+ */
+var verifyFile = async function (properties, path) {
+ if (properties.size !== undefined) {
+ let stat = await IOUtils.stat(path);
+ if (stat.size != properties.size) {
+ throw new Error(
+ "Downloaded file was " +
+ stat.size +
+ " bytes but expected " +
+ properties.size +
+ " bytes."
+ );
+ }
+ }
+
+ if (properties.hashFunction !== undefined) {
+ let expectedDigest = properties.hashValue.toLowerCase();
+ let digest = await IOUtils.computeHexDigest(path, properties.hashFunction);
+ if (digest != expectedDigest) {
+ throw new Error(
+ "Hash was `" + digest + "` but expected `" + expectedDigest + "`."
+ );
+ }
+ }
+};
+
+export const ProductAddonChecker = {
+ // More specific error names to help debug and report failures.
+ NETWORK_REQUEST_ERR: "NetworkRequestError",
+ NETWORK_TIMEOUT_ERR: "NetworkTimeoutError",
+ ABORT_ERR: "AbortError", // Doesn't have network prefix to work with existing convention.
+ VERIFICATION_MISSING_DATA_ERR: "VerificationMissingDataError",
+ VERIFICATION_FAILED_ERR: "VerificationFailedError",
+ VERIFICATION_INVALID_ERR: "VerificationInvalidError",
+ XML_PARSE_ERR: "XMLParseError",
+
+ /**
+ * Downloads a list of add-ons from a URL optionally testing the SSL
+ * certificate for certain attributes, and/or testing the content signature.
+ *
+ * @param url
+ * The url to download from.
+ * @param allowNonBuiltIn
+ * Whether to trust SSL certificates without a built-in CA issuer.
+ * @param allowedCerts
+ * The list of certificate attributes to match the SSL certificate
+ * against or null to skip checks.
+ * @param verifyContentSignature
+ * When true, will verify the content signature information from the
+ * response header. Failure to verify will result in an error.
+ * @return a promise that resolves to an object containing the list of add-ons
+ * and whether the local fallback was used, or rejects with a JS
+ * exception in case of error. In the case of an error, a best effort
+ * is made to set the error addonCheckerErr property to one of the
+ * more specific names used by the product addon checker.
+ */
+ getProductAddonList(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null,
+ verifyContentSignature = false
+ ) {
+ return downloadXML(
+ url,
+ allowNonBuiltIn,
+ allowedCerts,
+ verifyContentSignature
+ ).then(parseXML);
+ },
+
+ /**
+ * Downloads an add-on to a local file and checks that it matches the expected
+ * file. The caller is responsible for deleting the temporary file returned.
+ *
+ * @param addon
+ * The addon to download.
+ * @param options (optional)
+ * @param options.httpsOnlyNoUpgrade
+ * Prevents upgrade to https:// when HTTPS-Only Mode is enabled.
+ * @return a promise that resolves to the temporary file downloaded or rejects
+ * with a JS exception in case of error.
+ */
+ async downloadAddon(addon, options = { httpsOnlyNoUpgrade: false }) {
+ let path = await downloadFile(addon.URL, options);
+ try {
+ await verifyFile(addon, path);
+ return path;
+ } catch (e) {
+ await IOUtils.remove(path);
+ throw e;
+ }
+ },
+};
+
+// For test use only.
+export const ProductAddonCheckerTestUtils = {
+ /**
+ * Used to override ServiceRequest calls with a mock request.
+ * @param mockRequest The mocked ServiceRequest object.
+ * @param callback Method called with the overridden ServiceRequest. The override
+ * is undone after the callback returns.
+ */
+ async overrideServiceRequest(mockRequest, callback) {
+ let originalServiceRequest = lazy.ServiceRequest;
+ lazy.ServiceRequest = function () {
+ return mockRequest;
+ };
+ try {
+ return await callback();
+ } finally {
+ lazy.ServiceRequest = originalServiceRequest;
+ }
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs b/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs
new file mode 100644
index 0000000000..ccac484a1e
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs
@@ -0,0 +1,661 @@
+/* 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/. */
+
+import { computeSha256HashAsString } from "resource://gre/modules/addons/crypto-utils.sys.mjs";
+import {
+ GATED_PERMISSIONS,
+ SITEPERMS_ADDON_PROVIDER_PREF,
+ SITEPERMS_ADDON_TYPE,
+ isGatedPermissionType,
+ isKnownPublicSuffix,
+ isPrincipalInSitePermissionsBlocklist,
+} from "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+});
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "addonsBundle",
+ () => new Localization(["toolkit/about/aboutAddons.ftl"], true)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "SITEPERMS_ADDON_PROVIDER_ENABLED",
+ SITEPERMS_ADDON_PROVIDER_PREF,
+ false
+);
+
+const FIRST_CONTENT_PROCESS_TOPIC = "ipc:first-content-process-created";
+const SITEPERMS_ADDON_ID_SUFFIX = "@siteperms.mozilla.org";
+
+// Generate a per-session random salt, which is then used to generate
+// per-siteOrigin hashed strings used as the addon id in SitePermsAddonWrapper constructor
+// (expected to be matching new addon id generated for the same siteOrigin during
+// the same browsing session and different ones in new browsing sessions).
+//
+// NOTE: `generateSalt` is exported for testing purpose, should not be
+// used outside of tests.
+let SALT;
+export function generateSalt() {
+ // Throw if we're not in test and SALT is already defined
+ if (
+ typeof SALT !== "undefined" &&
+ !Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")
+ ) {
+ throw new Error("This should only be called from XPCShell tests");
+ }
+ SALT = crypto.getRandomValues(new Uint8Array(12)).join("");
+}
+
+function getSalt() {
+ if (!SALT) {
+ generateSalt();
+ }
+ return SALT;
+}
+
+class SitePermsAddonWrapper {
+ // An array of nsIPermission granted for the siteOrigin.
+ // We can't use a Set as handlePermissionChange might be called with different
+ // nsIPermission instance for the same permission (in the generic sense)
+ #permissions = [];
+
+ // This will be set to true in the `uninstall` method to recognize when a perm-changed notification
+ // is actually triggered by the SitePermsAddonWrapper uninstall method itself.
+ isUninstalling = false;
+
+ /**
+ * @param {string} siteOriginNoSuffix: The origin this addon is installed for
+ * WITHOUT the suffix generated from the
+ * origin attributes (see:
+ * nsIPrincipal.siteOriginNoSuffix).
+ * @param {Array<nsIPermission>} permissions: An array of the initial
+ * permissions the user granted
+ * for the addon origin.
+ */
+ constructor(siteOriginNoSuffix, permissions = []) {
+ this.siteOrigin = siteOriginNoSuffix;
+ this.principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ this.siteOrigin
+ );
+ // Use a template string for the concat in case `siteOrigin` isn't a string.
+ const saltedValue = `${this.siteOrigin}${getSalt()}`;
+ this.id = `${computeSha256HashAsString(
+ saltedValue
+ )}${SITEPERMS_ADDON_ID_SUFFIX}`;
+
+ for (const perm of permissions) {
+ this.#permissions.push(perm);
+ }
+ }
+
+ get isUninstalled() {
+ return this.#permissions.length === 0;
+ }
+
+ /**
+ * Returns the list of gated permissions types granted for the instance's origin
+ *
+ * @return {Array<String>}
+ */
+ get sitePermissions() {
+ return Array.from(new Set(this.#permissions.map(perm => perm.type)));
+ }
+
+ /**
+ * Update #permissions, and calls `uninstall` if there are no remaining gated permissions
+ * granted. This is called by SitePermsAddonProvider when it gets a "perm-changed" notification for a gated
+ * permission.
+ *
+ * @param {nsIPermission} permission: The permission being added/removed
+ * @param {String} action: The action perm-changed notifies us about
+ */
+ handlePermissionChange(permission, action) {
+ if (action == "added") {
+ this.#permissions.push(permission);
+ } else if (action == "deleted") {
+ // We want to remove the registered permission for the right principal (looking into originSuffix so we
+ // can unregister revoked permission on a specific context, private window, ...).
+ this.#permissions = this.#permissions.filter(
+ perm =>
+ !(
+ perm.type == permission.type &&
+ perm.principal.originSuffix === permission.principal.originSuffix
+ )
+ );
+
+ if (this.#permissions.length === 0) {
+ this.uninstall();
+ }
+ }
+ }
+
+ get type() {
+ return SITEPERMS_ADDON_TYPE;
+ }
+
+ get name() {
+ return lazy.addonsBundle.formatValueSync("addon-sitepermission-host", {
+ host: this.principal.host,
+ });
+ }
+
+ get creator() {}
+
+ get homepageURL() {}
+
+ get description() {}
+
+ get fullDescription() {}
+
+ get version() {
+ // We consider the previous implementation attempt (signed addons) to be the initial version,
+ // hence the 2.0 for this approach.
+ return "2.0";
+ }
+ get updateDate() {}
+
+ get isActive() {
+ return true;
+ }
+
+ get appDisabled() {
+ return false;
+ }
+
+ get userDisabled() {
+ return false;
+ }
+ set userDisabled(aVal) {}
+
+ get size() {
+ return 0;
+ }
+
+ async updateBlocklistState(options = {}) {}
+
+ get blocklistState() {
+ return Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ }
+
+ get scope() {
+ return lazy.AddonManager.SCOPE_APPLICATION;
+ }
+
+ get pendingOperations() {
+ return lazy.AddonManager.PENDING_NONE;
+ }
+
+ get operationsRequiringRestart() {
+ return lazy.AddonManager.OP_NEEDS_RESTART_NONE;
+ }
+
+ get permissions() {
+ // The addon only supports PERM_CAN_UNINSTALL and no other AOM permission.
+ return lazy.AddonManager.PERM_CAN_UNINSTALL;
+ }
+
+ get signedState() {
+ // Will make the permission prompt use the webextSitePerms.headerUnsignedWithPerms string
+ return lazy.AddonManager.SIGNEDSTATE_MISSING;
+ }
+
+ async enable() {}
+
+ async disable() {}
+
+ /**
+ * Uninstall the addon, calling AddonManager hooks and removing all granted permissions.
+ *
+ * @throws Services.perms.removeFromPrincipal could throw, see PermissionManager::AddInternal.
+ */
+ async uninstall() {
+ if (this.isUninstalling) {
+ return;
+ }
+ try {
+ this.isUninstalling = true;
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onUninstalling",
+ this,
+ false
+ );
+ const permissions = [...this.#permissions];
+ for (const permission of permissions) {
+ try {
+ Services.perms.removeFromPrincipal(
+ permission.principal,
+ permission.type
+ );
+ // Only remove the permission from the array if it was successfully removed from the principal
+ this.#permissions.splice(this.#permissions.indexOf(permission), 1);
+ } catch (err) {
+ Cu.reportError(err);
+ }
+ }
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+ } finally {
+ this.isUninstalling = false;
+ }
+ }
+
+ get isCompatible() {
+ return true;
+ }
+
+ get isPlatformCompatible() {
+ return true;
+ }
+
+ get providesUpdatesSecurely() {
+ return true;
+ }
+
+ get foreignInstall() {
+ return false;
+ }
+
+ get installTelemetryInfo() {
+ return { source: "siteperm-addon-provider", method: "synthetic-install" };
+ }
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ return true;
+ }
+}
+
+class SitePermsAddonInstalling extends SitePermsAddonWrapper {
+ #install = null;
+
+ /**
+ * @param {string} siteOriginNoSuffix: The origin this addon is installed
+ * for, WITHOUT the suffix generated from
+ * the origin attributes (see:
+ * nsIPrincipal.siteOriginNoSuffix).
+ * @param {SitePermsAddonInstall} install: The SitePermsAddonInstall instance
+ * calling this constructor.
+ */
+ constructor(siteOriginNoSuffix, install) {
+ // SitePermsAddonWrapper expect an array of nsIPermission as its second parameter.
+ // Since we don't have a proper permission here, we pass an object with the properties
+ // being used in the class.
+ const permission = {
+ principal: install.principal,
+ type: install.newSitePerm,
+ };
+
+ super(siteOriginNoSuffix, [permission]);
+ this.#install = install;
+ }
+
+ get existingAddon() {
+ return SitePermsAddonProvider.wrappersMapByOrigin.get(this.siteOrigin);
+ }
+
+ uninstall() {
+ // While about:addons tab is already open, new addon cards for newly installed
+ // addons are created from the `onInstalled` AOM events, for the `SitePermsAddonWrapper`
+ // the `onInstalling` and `onInstalled` events are emitted by `SitePermsAddonInstall`
+ // and the addon instance is going to be a `SitePermsAddonInstalling` instance if
+ // there wasn't an AddonCard for the same addon id yet.
+ //
+ // To make sure that all permissions will be uninstalled if a user uninstall the
+ // addon from an AddonCard created from a `SitePermsAddonInstalling` instance,
+ // we forward calls to the uninstall method of the existing `SitePermsAddonWrapper`
+ // instance being tracked by the `SitePermsAddonProvider`.
+ // If there isn't any then removing only the single permission added along with the
+ // `SitePremsAddonInstalling` is going to be enough.
+ if (this.existingAddon) {
+ return this.existingAddon.uninstall();
+ }
+
+ return super.uninstall();
+ }
+
+ validInstallOrigins() {
+ // Always return true from here,
+ // actual checks are done from AddonManagerInternal.getSitePermsAddonInstallForWebpage
+ return true;
+ }
+}
+
+// Numeric id included in the install telemetry events to correlate multiple events related
+// to the same install or update flow.
+let nextInstallId = 0;
+
+class SitePermsAddonInstall {
+ #listeners = new Set();
+ #installEvents = {
+ INSTALL_CANCELLED: "onInstallCancelled",
+ INSTALL_ENDED: "onInstallEnded",
+ INSTALL_FAILED: "onInstallFailed",
+ };
+
+ /**
+ * @param {nsIPrincipal} installingPrincipal
+ * @param {String} sitePerm
+ */
+ constructor(installingPrincipal, sitePerm) {
+ this.principal = installingPrincipal;
+ this.newSitePerm = sitePerm;
+ this.state = lazy.AddonManager.STATE_DOWNLOADED;
+ this.addon = new SitePermsAddonInstalling(
+ this.principal.siteOriginNoSuffix,
+ this
+ );
+ this.installId = ++nextInstallId;
+ }
+
+ get installTelemetryInfo() {
+ return this.addon.installTelemetryInfo;
+ }
+
+ async checkPrompt() {
+ // `promptHandler` can be set from `AddonManagerInternal.setupPromptHandler`
+ if (this.promptHandler) {
+ let info = {
+ // TODO: Investigate if we need to handle addon "update", i.e. granting new
+ // gated permission on an origin other permissions were already granted for (Bug 1790778).
+ existingAddon: null,
+ addon: this.addon,
+ icon: "chrome://mozapps/skin/extensions/category-sitepermission.svg",
+ // Used in AMTelemetry to detect the install flow related to this prompt.
+ install: this,
+ };
+
+ try {
+ await this.promptHandler(info);
+ } catch (err) {
+ if (this.error < 0) {
+ this.state = lazy.AddonManager.STATE_INSTALL_FAILED;
+ // In some cases onOperationCancelled is called during failures
+ // to install/uninstall/enable/disable addons. We may need to
+ // do that here in the future.
+ this.#callInstallListeners(this.#installEvents.INSTALL_FAILED);
+ } else if (this.state !== lazy.AddonManager.STATE_CANCELLED) {
+ this.cancel();
+ }
+ return;
+ }
+ }
+
+ this.state = lazy.AddonManager.STATE_PROMPTS_DONE;
+ this.install();
+ }
+
+ install() {
+ if (this.state === lazy.AddonManager.STATE_PROMPTS_DONE) {
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this.addon);
+ Services.perms.addFromPrincipal(
+ this.principal,
+ this.newSitePerm,
+ Services.perms.ALLOW_ACTION
+ );
+ this.state = lazy.AddonManager.STATE_INSTALLED;
+ this.#callInstallListeners(this.#installEvents.INSTALL_ENDED);
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this.addon);
+ this.addon.install = null;
+ return;
+ }
+
+ if (this.state !== lazy.AddonManager.STATE_DOWNLOADED) {
+ this.state = lazy.AddonManager.STATE_INSTALL_FAILED;
+ this.#callInstallListeners(this.#installEvents.INSTALL_FAILED);
+ return;
+ }
+
+ this.checkPrompt();
+ }
+
+ cancel() {
+ // This method can be called if the install is already cancelled.
+ // We don't want to go further in such case as it would lead to duplicated Telemetry events.
+ if (this.state == lazy.AddonManager.STATE_CANCELLED) {
+ console.error("SitePermsAddonInstall#cancel called twice on ", this);
+ return;
+ }
+
+ this.state = lazy.AddonManager.STATE_CANCELLED;
+ this.#callInstallListeners(this.#installEvents.INSTALL_CANCELLED);
+ }
+
+ /**
+ * Add a listener for the install events
+ *
+ * @param {Object} listener
+ * @param {Function} [listener.onDownloadEnded]
+ * @param {Function} [listener.onInstallCancelled]
+ * @param {Function} [listener.onInstallEnded]
+ * @param {Function} [listener.onInstallFailed]
+ */
+ addListener(listener) {
+ this.#listeners.add(listener);
+ }
+
+ /**
+ * Remove a listener
+ *
+ * @param {Object} listener: The same object reference that was used for `addListener`
+ */
+ removeListener(listener) {
+ this.#listeners.delete(listener);
+ }
+
+ /**
+ * Call the listeners callbacks for a given event.
+ *
+ * @param {String} eventName: The event to fire. Should be one of `this.#installEvents`
+ */
+ #callInstallListeners(eventName) {
+ if (!Object.values(this.#installEvents).includes(eventName)) {
+ console.warn(`Unknown "${eventName}" "event`);
+ return;
+ }
+
+ lazy.AddonManagerPrivate.callInstallListeners(
+ eventName,
+ Array.from(this.#listeners),
+ this
+ );
+ }
+}
+
+const SitePermsAddonProvider = {
+ get name() {
+ return "SitePermsAddonProvider";
+ },
+
+ wrappersMapByOrigin: new Map(),
+
+ /**
+ * Update wrappersMapByOrigin on perm-changed
+ *
+ * @param {nsIPermission} permission: The permission being added/removed
+ * @param {String} action: The action perm-changed notifies us about
+ */
+ handlePermissionChange(permission, action = "added") {
+ // Bail out if it it's not a gated perm
+ if (!isGatedPermissionType(permission.type)) {
+ return;
+ }
+
+ // Gated APIs should probably not be available on non-secure origins,
+ // but let's double check here.
+ if (permission.principal.scheme !== "https") {
+ return;
+ }
+
+ if (isPrincipalInSitePermissionsBlocklist(permission.principal)) {
+ return;
+ }
+
+ const { siteOriginNoSuffix } = permission.principal;
+
+ // Install origin cannot be on a known etld (e.g. github.io).
+ // We shouldn't get a permission change for those here, but let's
+ // be extra safe
+ if (isKnownPublicSuffix(siteOriginNoSuffix)) {
+ return;
+ }
+
+ // Pipe the change to the existing addon if there is one.
+ if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
+ this.wrappersMapByOrigin
+ .get(siteOriginNoSuffix)
+ .handlePermissionChange(permission, action);
+ }
+
+ if (action == "added") {
+ // We only have one SitePermsAddon per origin, handling multiple permissions.
+ if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
+ return;
+ }
+
+ const addonWrapper = new SitePermsAddonWrapper(siteOriginNoSuffix, [
+ permission,
+ ]);
+ this.wrappersMapByOrigin.set(siteOriginNoSuffix, addonWrapper);
+ return;
+ }
+
+ if (action == "deleted") {
+ if (!this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
+ return;
+ }
+ // Only remove the addon if it doesn't have any permissions left.
+ if (!this.wrappersMapByOrigin.get(siteOriginNoSuffix).isUninstalled) {
+ return;
+ }
+ this.wrappersMapByOrigin.delete(siteOriginNoSuffix);
+ }
+ },
+
+ /**
+ * Returns a Promise that resolves when handled the list of gated permissions
+ * and setup ther observer for the "perm-changed" event.
+ *
+ * @returns Promise
+ */
+ lazyInit() {
+ if (!this._initPromise) {
+ this._initPromise = new Promise(resolve => {
+ // Build the initial list of addons per origin
+ const perms = Services.perms.getAllByTypes(GATED_PERMISSIONS);
+ for (const perm of perms) {
+ this.handlePermissionChange(perm);
+ }
+ Services.obs.addObserver(this, "perm-changed");
+ resolve();
+ });
+ }
+ return this._initPromise;
+ },
+
+ shutdown() {
+ if (this._initPromise) {
+ Services.obs.removeObserver(this, "perm-changed");
+ }
+ this.wrappersMapByOrigin.clear();
+ this._initPromise = null;
+ },
+
+ /**
+ * Get a SitePermsAddonWrapper from an extension id
+ *
+ * @param {String|null|undefined} id: The extension id,
+ * @returns {SitePermsAddonWrapper|undefined}
+ */
+ async getAddonByID(id) {
+ await this.lazyInit();
+ if (!id?.endsWith?.(SITEPERMS_ADDON_ID_SUFFIX)) {
+ return undefined;
+ }
+
+ for (const addon of this.wrappersMapByOrigin.values()) {
+ if (addon.id === id) {
+ return addon;
+ }
+ }
+ return undefined;
+ },
+
+ /**
+ * Get a list of SitePermsAddonWrapper for a given list of extension types.
+ *
+ * @param {Array<String>|null|undefined} types: If null or undefined is passed,
+ * the callsites expect to get all the addons from the provider, without
+ * any filtering.
+ * @returns {Array<SitePermsAddonWrapper>}
+ */
+ async getAddonsByTypes(types) {
+ if (
+ !this.isEnabled ||
+ // `types` can be null/undefined, and in such case we _do_ want to return the addons.
+ (Array.isArray(types) && !types.includes(SITEPERMS_ADDON_TYPE))
+ ) {
+ return [];
+ }
+
+ await this.lazyInit();
+ return Array.from(this.wrappersMapByOrigin.values());
+ },
+
+ /**
+ * Create and return a SitePermsAddonInstall instance for a permission on a given principal
+ *
+ * @param {nsIPrincipal} installingPrincipal
+ * @param {String} sitePerm
+ * @returns {SitePermsAddonInstall}
+ */
+ getSitePermsAddonInstallForWebpage(installingPrincipal, sitePerm) {
+ return new SitePermsAddonInstall(installingPrincipal, sitePerm);
+ },
+
+ get isEnabled() {
+ return lazy.SITEPERMS_ADDON_PROVIDER_ENABLED;
+ },
+
+ observe(subject, topic, data) {
+ if (!this.isEnabled) {
+ return;
+ }
+
+ if (topic == FIRST_CONTENT_PROCESS_TOPIC) {
+ Services.obs.removeObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+
+ lazy.AddonManagerPrivate.registerProvider(SitePermsAddonProvider, [
+ SITEPERMS_ADDON_TYPE,
+ ]);
+ Services.obs.notifyObservers(null, "sitepermsaddon-provider-registered");
+ } else if (topic === "perm-changed") {
+ if (data === "cleared") {
+ // In such case, `subject` is null, but we can simply uninstall all existing addons.
+ for (const addon of this.wrappersMapByOrigin.values()) {
+ addon.uninstall();
+ }
+ this.wrappersMapByOrigin.clear();
+ return;
+ }
+
+ const perm = subject.QueryInterface(Ci.nsIPermission);
+ this.handlePermissionChange(perm, data);
+ }
+ },
+
+ addFirstContentProcessObserver() {
+ Services.obs.addObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+ },
+};
+
+// We want to register the SitePermsAddonProvider once the first content process gets created
+// (and only if the feature is also enabled through the "dom.sitepermsaddon-provider.enabled"
+// about:config pref).
+SitePermsAddonProvider.addFirstContentProcessObserver();
diff --git a/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs b/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs
new file mode 100644
index 0000000000..5d1d2c1970
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs
@@ -0,0 +1,3832 @@
+/* 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/. */
+
+/**
+ * This file contains most of the logic required to maintain the
+ * extensions database, including querying and modifying extension
+ * metadata. In general, we try to avoid loading it during startup when
+ * at all possible. Please keep that in mind when deciding whether to
+ * add code here or elsewhere.
+ */
+
+/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { XPIExports } from "resource://gre/modules/addons/XPIExports.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ ThirdPartyUtil: ["@mozilla.org/thirdpartyutil;1", "mozIThirdPartyUtil"],
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ Blocklist: "resource://gre/modules/Blocklist.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs",
+ PermissionsUtils: "resource://gre/modules/PermissionsUtils.sys.mjs",
+ QuarantinedDomains: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+});
+
+// WARNING: BuiltInThemes.sys.mjs may be provided by the host application (e.g.
+// Firefox), or it might not exist at all. Use with caution, as we don't
+// want things to completely fail if that module can't be loaded.
+ChromeUtils.defineLazyGetter(lazy, "BuiltInThemes", () => {
+ try {
+ let { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+ );
+ return BuiltInThemes;
+ } catch (e) {
+ Cu.reportError(`Unable to load BuiltInThemes.sys.mjs: ${e}`);
+ }
+ return undefined;
+});
+
+// A set of helpers to account from a single place that in some builds
+// (e.g. GeckoView and Thunderbird) the BuiltInThemes module may either
+// not be bundled at all or not be exposing the same methods provided
+// by the module as defined in Firefox Desktop.
+export const BuiltInThemesHelpers = {
+ getLocalizedColorwayGroupName(addonId) {
+ return lazy.BuiltInThemes?.getLocalizedColorwayGroupName?.(addonId);
+ },
+
+ getLocalizedColorwayDescription(addonId) {
+ return lazy.BuiltInThemes?.getLocalizedColorwayGroupDescription?.(addonId);
+ },
+
+ isActiveTheme(addonId) {
+ return lazy.BuiltInThemes?.isActiveTheme?.(addonId);
+ },
+
+ isRetainedExpiredTheme(addonId) {
+ return lazy.BuiltInThemes?.isRetainedExpiredTheme?.(addonId);
+ },
+
+ themeIsExpired(addonId) {
+ return lazy.BuiltInThemes?.themeIsExpired?.(addonId);
+ },
+
+ // Helper function called form XPInstall.sys.mjs to remove from the retained
+ // themes list the built-in colorways theme that have been migrated to a non
+ // built-in.
+ unretainMigratedColorwayTheme(addonId) {
+ lazy.BuiltInThemes?.unretainMigratedColorwayTheme?.(addonId);
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ BuiltInThemesHelpers,
+ "isColorwayMigrationEnabled",
+ "browser.theme.colorway-migration",
+ false
+);
+
+// A temporary hidden pref just meant to be used as a last resort, in case
+// we need to force-disable the "per-addon quarantined domains user controls"
+// feature during the beta cycle, e.g. if unexpected issues are caught late and
+// it shouldn't ride the train.
+//
+// TODO(Bug 1839616): remove this pref after the user controls features have been
+// released.
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "isQuarantineUIDisabled",
+ "extensions.quarantinedDomains.uiDisabled",
+ false
+);
+
+const { nsIBlocklistService } = Ci;
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.xpi-utils";
+
+const nsIFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+
+// Create a new logger for use by the Addons XPI Provider Utils
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+const FILE_JSON_DB = "extensions.json";
+
+const PREF_DB_SCHEMA = "extensions.databaseSchema";
+const PREF_EM_AUTO_DISABLED_SCOPES = "extensions.autoDisableScopes";
+const PREF_PENDING_OPERATIONS = "extensions.pendingOperations";
+const PREF_XPI_PERMISSIONS_BRANCH = "xpinstall.";
+const PREF_XPI_SIGNATURES_DEV_ROOT = "xpinstall.signatures.dev-root";
+
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+const KEY_APP_SYSTEM_ADDONS = "app-system-addons";
+const KEY_APP_SYSTEM_DEFAULTS = "app-system-defaults";
+const KEY_APP_SYSTEM_PROFILE = "app-system-profile";
+const KEY_APP_BUILTINS = "app-builtin";
+const KEY_APP_SYSTEM_LOCAL = "app-system-local";
+const KEY_APP_SYSTEM_SHARE = "app-system-share";
+const KEY_APP_GLOBAL = "app-global";
+const KEY_APP_PROFILE = "app-profile";
+const KEY_APP_TEMPORARY = "app-temporary";
+
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+
+// Properties to cache and reload when an addon installation is pending
+const PENDING_INSTALL_METADATA = [
+ "syncGUID",
+ "targetApplications",
+ "userDisabled",
+ "softDisabled",
+ "embedderDisabled",
+ "sourceURI",
+ "releaseNotesURI",
+ "installDate",
+ "updateDate",
+ "applyBackgroundUpdates",
+ "installTelemetryInfo",
+];
+
+// Properties to save in JSON file
+const PROP_JSON_FIELDS = [
+ "id",
+ "syncGUID",
+ "version",
+ "type",
+ "loader",
+ "updateURL",
+ "installOrigins",
+ "manifestVersion",
+ "optionsURL",
+ "optionsType",
+ "optionsBrowserStyle",
+ "aboutURL",
+ "defaultLocale",
+ "visible",
+ "active",
+ "userDisabled",
+ "appDisabled",
+ "embedderDisabled",
+ "pendingUninstall",
+ "installDate",
+ "updateDate",
+ "applyBackgroundUpdates",
+ "path",
+ "skinnable",
+ "sourceURI",
+ "releaseNotesURI",
+ "softDisabled",
+ "foreignInstall",
+ "strictCompatibility",
+ "locales",
+ "targetApplications",
+ "targetPlatforms",
+ "signedState",
+ "signedDate",
+ "seen",
+ "dependencies",
+ "incognito",
+ "userPermissions",
+ "optionalPermissions",
+ "sitePermissions",
+ "siteOrigin",
+ "icons",
+ "iconURL",
+ "blocklistState",
+ "blocklistURL",
+ "startupData",
+ "previewImage",
+ "hidden",
+ "installTelemetryInfo",
+ "recommendationState",
+ "rootURI",
+];
+
+const SIGNED_TYPES = new Set([
+ "extension",
+ "locale",
+ "theme",
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ "sitepermission-deprecated",
+]);
+
+// Time to wait before async save of XPI JSON database, in milliseconds
+const ASYNC_SAVE_DELAY_MS = 20;
+
+const l10n = new Localization(["browser/appExtensionFields.ftl"], true);
+
+/**
+ * Schedules an idle task, and returns a promise which resolves to an
+ * IdleDeadline when an idle slice is available. The caller should
+ * perform all of its idle work in the same micro-task, before the
+ * deadline is reached.
+ *
+ * @returns {Promise<IdleDeadline>}
+ */
+function promiseIdleSlice() {
+ return new Promise(resolve => {
+ ChromeUtils.idleDispatch(resolve);
+ });
+}
+
+let arrayForEach = Function.call.bind(Array.prototype.forEach);
+
+/**
+ * Loops over the given array, in the same way as Array forEach, but
+ * splitting the work among idle tasks.
+ *
+ * @param {Array} array
+ * The array to loop over.
+ * @param {function} func
+ * The function to call on each array element.
+ * @param {integer} [taskTimeMS = 5]
+ * The minimum time to allocate to each task. If less time than
+ * this is available in a given idle slice, and there are more
+ * elements to loop over, they will be deferred until the next
+ * idle slice.
+ */
+async function idleForEach(array, func, taskTimeMS = 5) {
+ let deadline;
+ for (let i = 0; i < array.length; i++) {
+ if (!deadline || deadline.timeRemaining() < taskTimeMS) {
+ deadline = await promiseIdleSlice();
+ }
+ func(array[i], i);
+ }
+}
+
+/**
+ * Asynchronously fill in the _repositoryAddon field for one addon
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to annotate.
+ * @returns {AddonInternal}
+ * The annotated add-on.
+ */
+async function getRepositoryAddon(aAddon) {
+ if (aAddon) {
+ aAddon._repositoryAddon = await lazy.AddonRepository.getCachedAddonByID(
+ aAddon.id
+ );
+ }
+ return aAddon;
+}
+
+/**
+ * Copies properties from one object to another. If no target object is passed
+ * a new object will be created and returned.
+ *
+ * @param {object} aObject
+ * An object to copy from
+ * @param {string[]} aProperties
+ * An array of properties to be copied
+ * @param {object?} [aTarget]
+ * An optional target object to copy the properties to
+ * @returns {Object}
+ * The object that the properties were copied onto
+ */
+function copyProperties(aObject, aProperties, aTarget) {
+ if (!aTarget) {
+ aTarget = {};
+ }
+ aProperties.forEach(function (aProp) {
+ if (aProp in aObject) {
+ aTarget[aProp] = aObject[aProp];
+ }
+ });
+ return aTarget;
+}
+
+// Maps instances of AddonInternal to AddonWrapper
+const wrapperMap = new WeakMap();
+let addonFor = wrapper => wrapperMap.get(wrapper);
+
+const EMPTY_ARRAY = Object.freeze([]);
+
+let AddonWrapper;
+
+/**
+ * The AddonInternal is an internal only representation of add-ons. It
+ * may have come from the database or an extension manifest.
+ */
+export class AddonInternal {
+ constructor(addonData) {
+ this._wrapper = null;
+ this._selectedLocale = null;
+ this.active = false;
+ this.visible = false;
+ this.userDisabled = false;
+ this.appDisabled = false;
+ this.softDisabled = false;
+ this.embedderDisabled = false;
+ this.blocklistState = Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ this.blocklistURL = null;
+ this.sourceURI = null;
+ this.releaseNotesURI = null;
+ this.foreignInstall = false;
+ this.seen = true;
+ this.skinnable = false;
+ this.startupData = null;
+ this._hidden = false;
+ this.installTelemetryInfo = null;
+ this.rootURI = null;
+ this._updateInstall = null;
+ this.recommendationState = null;
+
+ this.inDatabase = false;
+
+ /**
+ * @property {Array<string>} dependencies
+ * An array of bootstrapped add-on IDs on which this add-on depends.
+ * The add-on will remain appDisabled if any of the dependent
+ * add-ons is not installed and enabled.
+ */
+ this.dependencies = EMPTY_ARRAY;
+
+ if (addonData) {
+ copyProperties(addonData, PROP_JSON_FIELDS, this);
+ this.location = addonData.location;
+
+ if (!this.dependencies) {
+ this.dependencies = [];
+ }
+ Object.freeze(this.dependencies);
+
+ if (this.location) {
+ this.addedToDatabase();
+ }
+
+ this.sourceBundle = addonData._sourceBundle;
+ }
+ }
+
+ get sourceBundle() {
+ return this._sourceBundle;
+ }
+
+ set sourceBundle(file) {
+ this._sourceBundle = file;
+ if (file) {
+ this.rootURI = XPIExports.XPIInternal.getURIForResourceInFile(
+ file,
+ ""
+ ).spec;
+ }
+ }
+
+ get wrapper() {
+ if (!this._wrapper) {
+ this._wrapper = new AddonWrapper(this);
+ }
+ return this._wrapper;
+ }
+
+ get resolvedRootURI() {
+ return XPIExports.XPIInternal.maybeResolveURI(
+ Services.io.newURI(this.rootURI)
+ );
+ }
+
+ get isBuiltinColorwayTheme() {
+ return (
+ this.type === "theme" &&
+ this.location.isBuiltin &&
+ this.id.endsWith("-colorway@mozilla.org")
+ );
+ }
+
+ /**
+ * Validate a list of origins are contained in the installOrigins array (defined in manifest.json).
+ *
+ * SitePermission addons are a special case, where the triggering install site may be a subdomain
+ * of a valid xpi origin.
+ *
+ * @param {Object} origins Object containing URIs related to install.
+ * @params {nsIURI} origins.installFrom The nsIURI of the website that has triggered the install flow.
+ * @params {nsIURI} origins.source The nsIURI where the xpi is hosted.
+ * @returns {boolean}
+ */
+ validInstallOrigins({ installFrom, source }) {
+ if (
+ !Services.prefs.getBoolPref("extensions.install_origins.enabled", true)
+ ) {
+ return true;
+ }
+
+ let { installOrigins, manifestVersion } = this;
+ if (!installOrigins) {
+ // Install origins are mandatory in MV3 and optional
+ // in MV2. Old addons need to keep installing per the
+ // old install flow.
+ return manifestVersion < 3;
+ }
+ // An empty install_origins prevents any install from 3rd party websites.
+ if (!installOrigins.length) {
+ return false;
+ }
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ if (this.type == "sitepermission-deprecated") {
+ // NOTE: This may move into a check for all addons later.
+ for (let origin of installOrigins) {
+ let host = new URL(origin).host;
+ // install_origin cannot be on a known etld (e.g. github.io).
+ if (Services.eTLD.getKnownPublicSuffixFromHost(host) == host) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed from the install_origin ${host} that is an eTLD`
+ );
+ return false;
+ }
+ }
+
+ if (!installOrigins.includes(new URL(source.spec).origin)) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed, "${source.spec}" is not included in the Addon install_origins`
+ );
+ return false;
+ }
+
+ if (lazy.ThirdPartyUtil.isThirdPartyURI(source, installFrom)) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed, installFrom "${installFrom.spec}" is third party to the Addon install_origins`
+ );
+ return false;
+ }
+
+ return true;
+ }
+
+ for (const [name, uri] of Object.entries({ installFrom, source })) {
+ if (!installOrigins.includes(new URL(uri.spec).origin)) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed, ${name} "${uri.spec}" is not included in the Addon install_origins`
+ );
+ return false;
+ }
+ }
+ return true;
+ }
+
+ addedToDatabase() {
+ this._key = `${this.location.name}:${this.id}`;
+ this.inDatabase = true;
+ }
+
+ get isWebExtension() {
+ return this.loader == null;
+ }
+
+ get selectedLocale() {
+ if (this._selectedLocale) {
+ return this._selectedLocale;
+ }
+
+ /**
+ * this.locales is a list of objects that have property `locales`.
+ * It's value is an array of locale codes.
+ *
+ * First, we reduce this nested structure to a flat list of locale codes.
+ */
+ const locales = [].concat(...this.locales.map(loc => loc.locales));
+
+ let requestedLocales = Services.locale.requestedLocales;
+
+ /**
+ * If en-US is not in the list, add it as the last fallback.
+ */
+ if (!requestedLocales.includes("en-US")) {
+ requestedLocales.push("en-US");
+ }
+
+ /**
+ * Then we negotiate best locale code matching the app locales.
+ */
+ let bestLocale = Services.locale.negotiateLanguages(
+ requestedLocales,
+ locales,
+ "und",
+ Services.locale.langNegStrategyLookup
+ )[0];
+
+ /**
+ * If no match has been found, we'll assign the default locale as
+ * the selected one.
+ */
+ if (bestLocale === "und") {
+ this._selectedLocale = this.defaultLocale;
+ } else {
+ /**
+ * Otherwise, we'll go through all locale entries looking for the one
+ * that has the best match in it's locales list.
+ */
+ this._selectedLocale = this.locales.find(loc =>
+ loc.locales.includes(bestLocale)
+ );
+ }
+
+ return this._selectedLocale;
+ }
+
+ get providesUpdatesSecurely() {
+ return !this.updateURL || this.updateURL.startsWith("https:");
+ }
+
+ get isCorrectlySigned() {
+ switch (this.location.name) {
+ case KEY_APP_SYSTEM_PROFILE:
+ // Add-ons installed via Normandy must be signed by the system
+ // key or the "Mozilla Extensions" key.
+ return [
+ lazy.AddonManager.SIGNEDSTATE_SYSTEM,
+ lazy.AddonManager.SIGNEDSTATE_PRIVILEGED,
+ ].includes(this.signedState);
+ case KEY_APP_SYSTEM_ADDONS:
+ // System add-ons must be signed by the system key.
+ return this.signedState == lazy.AddonManager.SIGNEDSTATE_SYSTEM;
+
+ case KEY_APP_SYSTEM_DEFAULTS:
+ case KEY_APP_BUILTINS:
+ case KEY_APP_TEMPORARY:
+ // Temporary and built-in add-ons do not require signing.
+ return true;
+
+ case KEY_APP_SYSTEM_SHARE:
+ case KEY_APP_SYSTEM_LOCAL:
+ // On UNIX platforms except OSX, an additional location for system
+ // add-ons exists in /usr/{lib,share}/mozilla/extensions. Add-ons
+ // installed there do not require signing.
+ if (Services.appinfo.OS != "Darwin") {
+ return true;
+ }
+ break;
+ }
+
+ if (this.signedState === lazy.AddonManager.SIGNEDSTATE_NOT_REQUIRED) {
+ return true;
+ }
+ return this.signedState > lazy.AddonManager.SIGNEDSTATE_MISSING;
+ }
+
+ get isCompatible() {
+ return this.isCompatibleWith();
+ }
+
+ get isPrivileged() {
+ return lazy.ExtensionData.getIsPrivileged({
+ signedState: this.signedState,
+ builtIn: this.location.isBuiltin,
+ temporarilyInstalled: this.location.isTemporary,
+ });
+ }
+
+ get hidden() {
+ return (
+ this.location.hidden ||
+ // The hidden flag is intended to only be used for features that are part
+ // of the application. Temporary add-ons should not be hidden.
+ (this._hidden && this.isPrivileged && !this.location.isTemporary) ||
+ false
+ );
+ }
+
+ set hidden(val) {
+ this._hidden = val;
+ }
+
+ get disabled() {
+ return (
+ this.userDisabled ||
+ this.appDisabled ||
+ this.softDisabled ||
+ this.embedderDisabled
+ );
+ }
+
+ get isPlatformCompatible() {
+ if (!this.targetPlatforms.length) {
+ return true;
+ }
+
+ let matchedOS = false;
+
+ // If any targetPlatform matches the OS and contains an ABI then we will
+ // only match a targetPlatform that contains both the current OS and ABI
+ let needsABI = false;
+
+ // Some platforms do not specify an ABI, test against null in that case.
+ let abi = null;
+ try {
+ abi = Services.appinfo.XPCOMABI;
+ } catch (e) {}
+
+ // Something is causing errors in here
+ try {
+ for (let platform of this.targetPlatforms) {
+ if (platform.os == Services.appinfo.OS) {
+ if (platform.abi) {
+ needsABI = true;
+ if (platform.abi === abi) {
+ return true;
+ }
+ } else {
+ matchedOS = true;
+ }
+ }
+ }
+ } catch (e) {
+ let message =
+ "Problem with addon " +
+ this.id +
+ " targetPlatforms " +
+ JSON.stringify(this.targetPlatforms);
+ logger.error(message, e);
+ lazy.AddonManagerPrivate.recordException("XPI", message, e);
+ // don't trust this add-on
+ return false;
+ }
+
+ return matchedOS && !needsABI;
+ }
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ let app = this.matchingTargetApplication;
+ if (!app) {
+ return false;
+ }
+
+ // set reasonable defaults for minVersion and maxVersion
+ let minVersion = app.minVersion || "0";
+ let maxVersion = app.maxVersion || "*";
+
+ if (!aAppVersion) {
+ aAppVersion = Services.appinfo.version;
+ }
+ if (!aPlatformVersion) {
+ aPlatformVersion = Services.appinfo.platformVersion;
+ }
+
+ let version;
+ if (app.id == Services.appinfo.ID) {
+ version = aAppVersion;
+ } else if (app.id == TOOLKIT_ID) {
+ version = aPlatformVersion;
+ }
+
+ // Only extensions and dictionaries can be compatible by default; themes
+ // and language packs always use strict compatibility checking.
+ // Dictionaries are compatible by default unless requested by the dictinary.
+ if (
+ !this.strictCompatibility &&
+ (!lazy.AddonManager.strictCompatibility || this.type == "dictionary")
+ ) {
+ return Services.vc.compare(version, minVersion) >= 0;
+ }
+
+ return (
+ Services.vc.compare(version, minVersion) >= 0 &&
+ Services.vc.compare(version, maxVersion) <= 0
+ );
+ }
+
+ get matchingTargetApplication() {
+ let app = null;
+ for (let targetApp of this.targetApplications) {
+ if (targetApp.id == Services.appinfo.ID) {
+ return targetApp;
+ }
+ if (targetApp.id == TOOLKIT_ID) {
+ app = targetApp;
+ }
+ }
+ return app;
+ }
+
+ async findBlocklistEntry() {
+ return lazy.Blocklist.getAddonBlocklistEntry(this.wrapper);
+ }
+
+ async updateBlocklistState(options = {}) {
+ if (this.location.isSystem || this.location.isBuiltin) {
+ return;
+ }
+
+ let { applySoftBlock = true, updateDatabase = true } = options;
+
+ let oldState = this.blocklistState;
+
+ let entry = await this.findBlocklistEntry();
+ let newState = entry ? entry.state : Services.blocklist.STATE_NOT_BLOCKED;
+
+ this.blocklistState = newState;
+ this.blocklistURL = entry && entry.url;
+
+ let userDisabled, softDisabled;
+ // After a blocklist update, the blocklist service manually applies
+ // new soft blocks after displaying a UI, in which cases we need to
+ // skip updating it here.
+ if (applySoftBlock && oldState != newState) {
+ if (newState == Services.blocklist.STATE_SOFTBLOCKED) {
+ if (this.type == "theme") {
+ userDisabled = true;
+ } else {
+ softDisabled = !this.userDisabled;
+ }
+ } else {
+ softDisabled = false;
+ }
+ }
+
+ if (this.inDatabase && updateDatabase) {
+ await XPIDatabase.updateAddonDisabledState(this, {
+ userDisabled,
+ softDisabled,
+ });
+ XPIDatabase.saveChanges();
+ } else {
+ this.appDisabled = !XPIDatabase.isUsableAddon(this);
+ if (userDisabled !== undefined) {
+ this.userDisabled = userDisabled;
+ }
+ if (softDisabled !== undefined) {
+ this.softDisabled = softDisabled;
+ }
+ }
+ }
+
+ recordAddonBlockChangeTelemetry(reason) {
+ lazy.Blocklist.recordAddonBlockChangeTelemetry(this.wrapper, reason);
+ }
+
+ async setUserDisabled(val, allowSystemAddons = false) {
+ if (val == (this.userDisabled || this.softDisabled)) {
+ return;
+ }
+
+ if (this.inDatabase) {
+ // System add-ons should not be user disabled, as there is no UI to
+ // re-enable them.
+ if (this.location.isSystem && !allowSystemAddons) {
+ throw new Error(`Cannot disable system add-on ${this.id}`);
+ }
+ await XPIDatabase.updateAddonDisabledState(this, { userDisabled: val });
+ } else {
+ this.userDisabled = val;
+ // When enabling remove the softDisabled flag
+ if (!val) {
+ this.softDisabled = false;
+ }
+ }
+ }
+
+ applyCompatibilityUpdate(aUpdate, aSyncCompatibility) {
+ let wasCompatible = this.isCompatible;
+
+ for (let targetApp of this.targetApplications) {
+ for (let updateTarget of aUpdate.targetApplications) {
+ if (
+ targetApp.id == updateTarget.id &&
+ (aSyncCompatibility ||
+ Services.vc.compare(targetApp.maxVersion, updateTarget.maxVersion) <
+ 0)
+ ) {
+ targetApp.minVersion = updateTarget.minVersion;
+ targetApp.maxVersion = updateTarget.maxVersion;
+
+ if (this.inDatabase) {
+ XPIDatabase.saveChanges();
+ }
+ }
+ }
+ }
+
+ if (wasCompatible != this.isCompatible) {
+ if (this.inDatabase) {
+ XPIDatabase.updateAddonDisabledState(this);
+ } else {
+ this.appDisabled = !XPIDatabase.isUsableAddon(this);
+ }
+ }
+ }
+
+ toJSON() {
+ let obj = copyProperties(this, PROP_JSON_FIELDS);
+ obj.location = this.location.name;
+ return obj;
+ }
+
+ /**
+ * When an add-on install is pending its metadata will be cached in a file.
+ * This method reads particular properties of that metadata that may be newer
+ * than that in the extension manifest, like compatibility information.
+ *
+ * @param {Object} aObj
+ * A JS object containing the cached metadata
+ */
+ importMetadata(aObj) {
+ for (let prop of PENDING_INSTALL_METADATA) {
+ if (!(prop in aObj)) {
+ continue;
+ }
+
+ this[prop] = aObj[prop];
+ }
+
+ // Compatibility info may have changed so update appDisabled
+ this.appDisabled = !XPIDatabase.isUsableAddon(this);
+ }
+
+ permissions() {
+ let permissions = 0;
+
+ // Add-ons that aren't installed cannot be modified in any way
+ if (!this.inDatabase) {
+ return permissions;
+ }
+
+ if (!this.appDisabled) {
+ if (this.userDisabled || this.softDisabled) {
+ permissions |= lazy.AddonManager.PERM_CAN_ENABLE;
+ } else if (this.type != "theme" || this.id != DEFAULT_THEME_ID) {
+ // We do not expose disabling the default theme.
+ permissions |= lazy.AddonManager.PERM_CAN_DISABLE;
+ }
+ }
+
+ // Add-ons that are in locked install locations, or are pending uninstall
+ // cannot be uninstalled or upgraded. One caveat is extensions sideloaded
+ // from non-profile locations. Since Firefox 73(?), new sideloaded extensions
+ // from outside the profile have not been installed so any such extensions
+ // must be from an older profile. Users may uninstall such an extension which
+ // removes the related state from this profile but leaves the actual file alone
+ // (since it is outside this profile and may be in use in other profiles)
+ let changesAllowed = !this.location.locked && !this.pendingUninstall;
+ if (changesAllowed) {
+ // System add-on upgrades are triggered through a different mechanism (see updateSystemAddons())
+ // Builtin addons are only upgraded with Firefox (or app) updates.
+ let isSystem = this.location.isSystem || this.location.isBuiltin;
+ // Add-ons that are installed by a file link cannot be upgraded.
+ if (!isSystem && !this.location.isLinkedAddon(this.id)) {
+ permissions |= lazy.AddonManager.PERM_CAN_UPGRADE;
+ }
+ // Allow active and retained colorways builtin themes to be updated to
+ // the same theme hosted on AMO (the PERM_CAN_UPGRADE permission will
+ // ensure we will be asking AMO for an update, then the AMO addon xpi
+ // will be installed in the profile location, overridden in the
+ // `createUpdate` defined in `XPIInstall.sys.mjs` and called from
+ // `UpdateChecker` `onUpdateCheckComplete` method).
+ if (
+ this.isBuiltinColorwayTheme &&
+ BuiltInThemesHelpers.isColorwayMigrationEnabled &&
+ BuiltInThemesHelpers.themeIsExpired(this.id) &&
+ (BuiltInThemesHelpers.isActiveTheme(this.id) ||
+ BuiltInThemesHelpers.isRetainedExpiredTheme(this.id))
+ ) {
+ permissions |= lazy.AddonManager.PERM_CAN_UPGRADE;
+ }
+ }
+
+ // We allow uninstall of legacy sideloaded extensions, even when in locked locations,
+ // but we do not remove the addon file in that case.
+ let isLegacySideload =
+ this.foreignInstall &&
+ !(this.location.scope & lazy.AddonSettings.SCOPES_SIDELOAD);
+ if (changesAllowed || isLegacySideload) {
+ permissions |= lazy.AddonManager.PERM_API_CAN_UNINSTALL;
+ if (!this.location.isBuiltin) {
+ permissions |= lazy.AddonManager.PERM_CAN_UNINSTALL;
+ }
+ }
+
+ // The permission to "toggle the private browsing access" is locked down
+ // when the extension has opted out or it gets the permission automatically
+ // on every extension startup (as system, privileged and builtin addons).
+ if (
+ (this.type === "extension" ||
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ this.type == "sitepermission-deprecated") &&
+ this.incognito !== "not_allowed" &&
+ this.signedState !== lazy.AddonManager.SIGNEDSTATE_PRIVILEGED &&
+ this.signedState !== lazy.AddonManager.SIGNEDSTATE_SYSTEM &&
+ !this.location.isBuiltin
+ ) {
+ permissions |= lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS;
+ }
+
+ if (Services.policies) {
+ if (!Services.policies.isAllowed(`uninstall-extension:${this.id}`)) {
+ permissions &= ~lazy.AddonManager.PERM_CAN_UNINSTALL;
+ }
+ if (!Services.policies.isAllowed(`disable-extension:${this.id}`)) {
+ permissions &= ~lazy.AddonManager.PERM_CAN_DISABLE;
+ }
+ if (Services.policies.getExtensionSettings(this.id)?.updates_disabled) {
+ permissions &= ~lazy.AddonManager.PERM_CAN_UPGRADE;
+ }
+ }
+
+ return permissions;
+ }
+
+ propagateDisabledState(oldAddon) {
+ if (oldAddon) {
+ this.userDisabled = oldAddon.userDisabled;
+ this.embedderDisabled = oldAddon.embedderDisabled;
+ this.softDisabled = oldAddon.softDisabled;
+ this.blocklistState = oldAddon.blocklistState;
+ }
+ }
+}
+
+/**
+ * The AddonWrapper wraps an Addon to provide the data visible to consumers of
+ * the public API.
+ *
+ * NOTE: Do not add any new logic here. Add it to AddonInternal and expose
+ * through defineAddonWrapperProperty after this class definition.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on object to wrap.
+ */
+AddonWrapper = class {
+ constructor(aAddon) {
+ wrapperMap.set(this, aAddon);
+ }
+
+ get __AddonInternal__() {
+ return addonFor(this);
+ }
+
+ get quarantineIgnoredByApp() {
+ return this.isPrivileged || !!this.recommendationStates?.length;
+ }
+
+ get quarantineIgnoredByUser() {
+ // NOTE: confirm if this getter could be replaced by a
+ // lazy preference getter and the addon wrapper to not be
+ // kept around longer by the pref observer registered
+ // internally by the lazy getter.
+ return lazy.QuarantinedDomains.isUserAllowedAddonId(this.id);
+ }
+
+ set quarantineIgnoredByUser(val) {
+ lazy.QuarantinedDomains.setUserAllowedAddonIdPref(this.id, !!val);
+ }
+
+ get canChangeQuarantineIgnored() {
+ // Never show the quarantined domains user controls UI if the
+ // quarantined domains feature is disabled.
+ return (
+ WebExtensionPolicy.quarantinedDomainsEnabled &&
+ !lazy.isQuarantineUIDisabled &&
+ this.type === "extension" &&
+ !this.quarantineIgnoredByApp
+ );
+ }
+
+ get seen() {
+ return addonFor(this).seen;
+ }
+
+ markAsSeen() {
+ addonFor(this).seen = true;
+ XPIDatabase.saveChanges();
+ }
+
+ get installTelemetryInfo() {
+ const addon = addonFor(this);
+ if (!addon.installTelemetryInfo && addon.location) {
+ if (addon.location.isSystem) {
+ return { source: "system-addon" };
+ }
+
+ if (addon.location.isTemporary) {
+ return { source: "temporary-addon" };
+ }
+ }
+
+ return addon.installTelemetryInfo;
+ }
+
+ get temporarilyInstalled() {
+ return addonFor(this).location.isTemporary;
+ }
+
+ get aboutURL() {
+ return this.isActive ? addonFor(this).aboutURL : null;
+ }
+
+ get optionsURL() {
+ if (!this.isActive) {
+ return null;
+ }
+
+ let addon = addonFor(this);
+ if (addon.optionsURL) {
+ if (this.isWebExtension) {
+ // The internal object's optionsURL property comes from the addons
+ // DB and should be a relative URL. However, extensions with
+ // options pages installed before bug 1293721 was fixed got absolute
+ // URLs in the addons db. This code handles both cases.
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ if (!policy) {
+ return null;
+ }
+ let base = policy.getURL();
+ return new URL(addon.optionsURL, base).href;
+ }
+ return addon.optionsURL;
+ }
+
+ return null;
+ }
+
+ get optionsType() {
+ if (!this.isActive) {
+ return null;
+ }
+
+ let addon = addonFor(this);
+ let hasOptionsURL = !!this.optionsURL;
+
+ if (addon.optionsType) {
+ switch (parseInt(addon.optionsType, 10)) {
+ case lazy.AddonManager.OPTIONS_TYPE_TAB:
+ case lazy.AddonManager.OPTIONS_TYPE_INLINE_BROWSER:
+ return hasOptionsURL ? addon.optionsType : null;
+ }
+ return null;
+ }
+
+ return null;
+ }
+
+ get optionsBrowserStyle() {
+ let addon = addonFor(this);
+ return addon.optionsBrowserStyle;
+ }
+
+ get incognito() {
+ return addonFor(this).incognito;
+ }
+
+ async getBlocklistURL() {
+ return addonFor(this).blocklistURL;
+ }
+
+ get iconURL() {
+ return lazy.AddonManager.getPreferredIconURL(this, 48);
+ }
+
+ get icons() {
+ let addon = addonFor(this);
+ let icons = {};
+
+ if (addon._repositoryAddon) {
+ for (let size in addon._repositoryAddon.icons) {
+ icons[size] = addon._repositoryAddon.icons[size];
+ }
+ }
+
+ if (addon.icons) {
+ for (let size in addon.icons) {
+ let path = addon.icons[size].replace(/^\//, "");
+ icons[size] = this.getResourceURI(path).spec;
+ }
+ }
+
+ let canUseIconURLs = this.isActive;
+ if (canUseIconURLs && addon.iconURL) {
+ icons[32] = addon.iconURL;
+ icons[48] = addon.iconURL;
+ }
+
+ Object.freeze(icons);
+ return icons;
+ }
+
+ get screenshots() {
+ let addon = addonFor(this);
+ let repositoryAddon = addon._repositoryAddon;
+ if (repositoryAddon && "screenshots" in repositoryAddon) {
+ let repositoryScreenshots = repositoryAddon.screenshots;
+ if (repositoryScreenshots && repositoryScreenshots.length) {
+ return repositoryScreenshots;
+ }
+ }
+
+ if (addon.previewImage) {
+ let url = this.getResourceURI(addon.previewImage).spec;
+ return [new lazy.AddonManagerPrivate.AddonScreenshot(url)];
+ }
+
+ return null;
+ }
+
+ get recommendationStates() {
+ let addon = addonFor(this);
+ let state = addon.recommendationState;
+ if (
+ state &&
+ state.validNotBefore < addon.updateDate &&
+ state.validNotAfter > addon.updateDate &&
+ addon.isCorrectlySigned &&
+ !this.temporarilyInstalled
+ ) {
+ return state.states;
+ }
+ return [];
+ }
+
+ // NOTE: this boolean getter doesn't return true for all recommendation
+ // states at the moment. For the states actually supported on the autograph
+ // side see:
+ // https://github.com/mozilla-services/autograph/blob/8a34847a/autograph.yaml#L1456-L1460
+ get isRecommended() {
+ return this.recommendationStates.includes("recommended");
+ }
+
+ get canBypassThirdParyInstallPrompt() {
+ // We only bypass if the extension is signed (to support distributions
+ // that turn off the signing requirement) and has recommendation states,
+ // or the extension is signed as privileged.
+ return (
+ this.signedState == lazy.AddonManager.SIGNEDSTATE_PRIVILEGED ||
+ (this.signedState >= lazy.AddonManager.SIGNEDSTATE_SIGNED &&
+ this.recommendationStates.length)
+ );
+ }
+
+ get applyBackgroundUpdates() {
+ return addonFor(this).applyBackgroundUpdates;
+ }
+ set applyBackgroundUpdates(val) {
+ let addon = addonFor(this);
+ if (
+ val != lazy.AddonManager.AUTOUPDATE_DEFAULT &&
+ val != lazy.AddonManager.AUTOUPDATE_DISABLE &&
+ val != lazy.AddonManager.AUTOUPDATE_ENABLE
+ ) {
+ val = val
+ ? lazy.AddonManager.AUTOUPDATE_DEFAULT
+ : lazy.AddonManager.AUTOUPDATE_DISABLE;
+ }
+
+ if (val == addon.applyBackgroundUpdates) {
+ return;
+ }
+
+ XPIDatabase.setAddonProperties(addon, {
+ applyBackgroundUpdates: val,
+ });
+ lazy.AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
+ "applyBackgroundUpdates",
+ ]);
+ }
+
+ set syncGUID(val) {
+ let addon = addonFor(this);
+ if (addon.syncGUID == val) {
+ return;
+ }
+
+ if (addon.inDatabase) {
+ XPIDatabase.setAddonSyncGUID(addon, val);
+ }
+
+ addon.syncGUID = val;
+ }
+
+ get install() {
+ let addon = addonFor(this);
+ if (!("_install" in addon) || !addon._install) {
+ return null;
+ }
+ return addon._install.wrapper;
+ }
+
+ get updateInstall() {
+ let addon = addonFor(this);
+ return addon._updateInstall ? addon._updateInstall.wrapper : null;
+ }
+
+ get pendingUpgrade() {
+ let addon = addonFor(this);
+ return addon.pendingUpgrade ? addon.pendingUpgrade.wrapper : null;
+ }
+
+ get scope() {
+ let addon = addonFor(this);
+ if (addon.location) {
+ return addon.location.scope;
+ }
+
+ return lazy.AddonManager.SCOPE_PROFILE;
+ }
+
+ get pendingOperations() {
+ let addon = addonFor(this);
+ let pending = 0;
+ if (!addon.inDatabase) {
+ // Add-on is pending install if there is no associated install (shouldn't
+ // happen here) or if the install is in the process of or has successfully
+ // completed the install. If an add-on is pending install then we ignore
+ // any other pending operations.
+ if (
+ !addon._install ||
+ addon._install.state == lazy.AddonManager.STATE_INSTALLING ||
+ addon._install.state == lazy.AddonManager.STATE_INSTALLED
+ ) {
+ return lazy.AddonManager.PENDING_INSTALL;
+ }
+ } else if (addon.pendingUninstall) {
+ // If an add-on is pending uninstall then we ignore any other pending
+ // operations
+ return lazy.AddonManager.PENDING_UNINSTALL;
+ }
+
+ if (addon.active && addon.disabled) {
+ pending |= lazy.AddonManager.PENDING_DISABLE;
+ } else if (!addon.active && !addon.disabled) {
+ pending |= lazy.AddonManager.PENDING_ENABLE;
+ }
+
+ if (addon.pendingUpgrade) {
+ pending |= lazy.AddonManager.PENDING_UPGRADE;
+ }
+
+ return pending;
+ }
+
+ get operationsRequiringRestart() {
+ return 0;
+ }
+
+ get isDebuggable() {
+ return this.isActive;
+ }
+
+ get permissions() {
+ return addonFor(this).permissions();
+ }
+
+ get isActive() {
+ let addon = addonFor(this);
+ if (!addon.active) {
+ return false;
+ }
+ if (!Services.appinfo.inSafeMode) {
+ return true;
+ }
+ return XPIExports.XPIInternal.canRunInSafeMode(addon);
+ }
+
+ get startupPromise() {
+ let addon = addonFor(this);
+ if (!this.isActive) {
+ return null;
+ }
+
+ let activeAddon = XPIExports.XPIProvider.activeAddons.get(addon.id);
+ if (activeAddon) {
+ return activeAddon.startupPromise || null;
+ }
+ return null;
+ }
+
+ updateBlocklistState(applySoftBlock = true) {
+ return addonFor(this).updateBlocklistState({ applySoftBlock });
+ }
+
+ get userDisabled() {
+ let addon = addonFor(this);
+ return addon.softDisabled || addon.userDisabled;
+ }
+
+ /**
+ * Get the embedderDisabled property for this addon.
+ *
+ * This is intended for embedders of Gecko like GeckoView apps to control
+ * which addons are usable on their app.
+ *
+ * @returns {boolean}
+ */
+ get embedderDisabled() {
+ if (!lazy.AddonSettings.IS_EMBEDDED) {
+ return undefined;
+ }
+
+ return addonFor(this).embedderDisabled;
+ }
+
+ /**
+ * Set the embedderDisabled property for this addon.
+ *
+ * This is intended for embedders of Gecko like GeckoView apps to control
+ * which addons are usable on their app.
+ *
+ * Embedders can disable addons for various reasons, e.g. the addon is not
+ * compatible with their implementation of the WebExtension API.
+ *
+ * When an addon is embedderDisabled it will behave like it was appDisabled.
+ *
+ * @param {boolean} val
+ * whether this addon should be embedder disabled or not.
+ */
+ async setEmbedderDisabled(val) {
+ if (!lazy.AddonSettings.IS_EMBEDDED) {
+ throw new Error("Setting embedder disabled while not embedding.");
+ }
+
+ let addon = addonFor(this);
+ if (addon.embedderDisabled == val) {
+ return val;
+ }
+
+ if (addon.inDatabase) {
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ embedderDisabled: val,
+ });
+ } else {
+ addon.embedderDisabled = val;
+ }
+
+ return val;
+ }
+
+ enable(options = {}) {
+ const { allowSystemAddons = false } = options;
+ return addonFor(this).setUserDisabled(false, allowSystemAddons);
+ }
+
+ disable(options = {}) {
+ const { allowSystemAddons = false } = options;
+ return addonFor(this).setUserDisabled(true, allowSystemAddons);
+ }
+
+ async setSoftDisabled(val) {
+ let addon = addonFor(this);
+ if (val == addon.softDisabled) {
+ return val;
+ }
+
+ if (addon.inDatabase) {
+ // When softDisabling a theme just enable the active theme
+ if (addon.type === "theme" && val && !addon.userDisabled) {
+ if (addon.isWebExtension) {
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ softDisabled: val,
+ });
+ }
+ } else {
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ softDisabled: val,
+ });
+ }
+ } else if (!addon.userDisabled) {
+ // Only set softDisabled if not already disabled
+ addon.softDisabled = val;
+ }
+
+ return val;
+ }
+
+ get isPrivileged() {
+ return addonFor(this).isPrivileged;
+ }
+
+ get hidden() {
+ return addonFor(this).hidden;
+ }
+
+ get isSystem() {
+ let addon = addonFor(this);
+ return addon.location.isSystem;
+ }
+
+ get isBuiltin() {
+ return addonFor(this).location.isBuiltin;
+ }
+
+ // Returns true if Firefox Sync should sync this addon. Only addons
+ // in the profile install location are considered syncable.
+ get isSyncable() {
+ let addon = addonFor(this);
+ return addon.location.name == KEY_APP_PROFILE;
+ }
+
+ get userPermissions() {
+ return addonFor(this).userPermissions;
+ }
+
+ get optionalPermissions() {
+ return addonFor(this).optionalPermissions;
+ }
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ return addonFor(this).isCompatibleWith(aAppVersion, aPlatformVersion);
+ }
+
+ async uninstall(alwaysAllowUndo) {
+ let addon = addonFor(this);
+ return XPIExports.XPIInstall.uninstallAddon(addon, alwaysAllowUndo);
+ }
+
+ cancelUninstall() {
+ let addon = addonFor(this);
+ XPIExports.XPIInstall.cancelUninstallAddon(addon);
+ }
+
+ findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) {
+ new XPIExports.UpdateChecker(
+ addonFor(this),
+ aListener,
+ aReason,
+ aAppVersion,
+ aPlatformVersion
+ );
+ }
+
+ // Returns true if there was an update in progress, false if there was no update to cancel
+ cancelUpdate() {
+ let addon = addonFor(this);
+ if (addon._updateCheck) {
+ addon._updateCheck.cancel();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Reloads the add-on.
+ *
+ * For temporarily installed add-ons, this uninstalls and re-installs the
+ * add-on. Otherwise, the addon is disabled and then re-enabled, and the cache
+ * is flushed.
+ */
+ async reload() {
+ const addon = addonFor(this);
+
+ logger.debug(`reloading add-on ${addon.id}`);
+
+ if (!this.temporarilyInstalled) {
+ await XPIDatabase.updateAddonDisabledState(addon, { userDisabled: true });
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ userDisabled: false,
+ });
+ } else {
+ // This function supports re-installing an existing add-on.
+ await lazy.AddonManager.installTemporaryAddon(addon._sourceBundle);
+ }
+ }
+
+ /**
+ * Returns a URI to the selected resource or to the add-on bundle if aPath
+ * is null. URIs to the bundle will always be file: URIs. URIs to resources
+ * will be file: URIs if the add-on is unpacked or jar: URIs if the add-on is
+ * still an XPI file.
+ *
+ * @param {string?} aPath
+ * The path in the add-on to get the URI for or null to get a URI to
+ * the file or directory the add-on is installed as.
+ * @returns {nsIURI}
+ */
+ getResourceURI(aPath) {
+ let addon = addonFor(this);
+ let url = Services.io.newURI(addon.rootURI);
+ if (aPath) {
+ if (aPath.startsWith("/")) {
+ throw new Error("getResourceURI() must receive a relative path");
+ }
+ url = Services.io.newURI(aPath, null, url);
+ }
+ return url;
+ }
+};
+
+function chooseValue(aAddon, aObj, aProp) {
+ let repositoryAddon = aAddon._repositoryAddon;
+ let objValue = aObj[aProp];
+
+ if (
+ repositoryAddon &&
+ aProp in repositoryAddon &&
+ (aProp === "creator" || objValue == null)
+ ) {
+ return [repositoryAddon[aProp], true];
+ }
+
+ return [objValue, false];
+}
+
+function defineAddonWrapperProperty(name, getter) {
+ Object.defineProperty(AddonWrapper.prototype, name, {
+ get: getter,
+ enumerable: true,
+ });
+}
+
+[
+ "id",
+ "syncGUID",
+ "version",
+ "type",
+ "isWebExtension",
+ "isCompatible",
+ "isPlatformCompatible",
+ "providesUpdatesSecurely",
+ "blocklistState",
+ "appDisabled",
+ "softDisabled",
+ "skinnable",
+ "foreignInstall",
+ "strictCompatibility",
+ "updateURL",
+ "installOrigins",
+ "manifestVersion",
+ "validInstallOrigins",
+ "dependencies",
+ "signedState",
+ "sitePermissions",
+ "siteOrigin",
+ "isCorrectlySigned",
+ "isBuiltinColorwayTheme",
+].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+ return aProp in addon ? addon[aProp] : undefined;
+ });
+});
+
+[
+ "fullDescription",
+ "supportURL",
+ "contributionURL",
+ "averageRating",
+ "reviewCount",
+ "reviewURL",
+ "weeklyDownloads",
+ "amoListingURL",
+].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+ if (addon._repositoryAddon) {
+ return addon._repositoryAddon[aProp];
+ }
+
+ return null;
+ });
+});
+
+["installDate", "updateDate"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+ // installDate is always set, updateDate is sometimes missing.
+ return new Date(addon[aProp] ?? addon.installDate);
+ });
+});
+
+defineAddonWrapperProperty("signedDate", function () {
+ let addon = addonFor(this);
+ let { signedDate } = addon;
+ if (signedDate != null) {
+ return new Date(signedDate);
+ }
+ return null;
+});
+
+["sourceURI", "releaseNotesURI"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+
+ // Temporary Installed Addons do not have a "sourceURI",
+ // But we can use the "_sourceBundle" as an alternative,
+ // which points to the path of the addon xpi installed
+ // or its source dir (if it has been installed from a
+ // directory).
+ if (aProp == "sourceURI" && this.temporarilyInstalled) {
+ return Services.io.newFileURI(addon._sourceBundle);
+ }
+
+ let [target, fromRepo] = chooseValue(addon, addon, aProp);
+ if (!target) {
+ return null;
+ }
+ if (fromRepo) {
+ return target;
+ }
+ return Services.io.newURI(target);
+ });
+});
+
+// Add to this Map if you need to change an addon's Fluent ID. Keep it in sync
+// with the list in browser_verify_l10n_strings.js
+const updatedAddonFluentIds = new Map([
+ ["extension-default-theme-name", "extension-default-theme-name-auto"],
+]);
+
+["name", "description", "creator", "homepageURL"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+
+ let formattedMessage;
+ // We want to make sure that all built-in themes that are localizable can
+ // actually localized, particularly those for thunderbird and desktop.
+ if (
+ (aProp === "name" || aProp === "description") &&
+ addon.location.name === KEY_APP_BUILTINS &&
+ addon.type === "theme"
+ ) {
+ // Built-in themes are localized with Fluent instead of the WebExtension API.
+ let addonIdPrefix = addon.id.replace("@mozilla.org", "");
+ const colorwaySuffix = "colorway";
+ if (addonIdPrefix.endsWith(colorwaySuffix)) {
+ // FIXME: Depending on BuiltInThemes here is sort of a hack. Bug 1733466
+ // would provide a more generalized way of doing this.
+ if (aProp == "description") {
+ return BuiltInThemesHelpers.getLocalizedColorwayDescription(addon.id);
+ }
+ // Colorway collections are usually divided into and presented as
+ // "groups". A group either contains closely related colorways, e.g.
+ // stemming from the same base color but with different intensities, or
+ // if the current collection doesn't have intensities, each colorway is
+ // their own group. Colorway names combine the group name with an
+ // intensity. Their ids have the format
+ // {colorwayGroup}-{intensity}-colorway@mozilla.org or
+ // {colorwayGroupName}-colorway@mozilla.org). L10n for colorway group
+ // names is optional and falls back on the unlocalized name from the
+ // theme's manifest. The intensity part, if present, must be localized.
+ let localizedColorwayGroupName =
+ BuiltInThemesHelpers.getLocalizedColorwayGroupName(addon.id);
+ let [colorwayGroupName, intensity] = addonIdPrefix.split("-", 2);
+ if (intensity == colorwaySuffix) {
+ // This theme doesn't have an intensity.
+ return localizedColorwayGroupName || addon.defaultLocale.name;
+ }
+ // We're not using toLocaleUpperCase because these color names are
+ // always in English.
+ colorwayGroupName =
+ localizedColorwayGroupName ||
+ colorwayGroupName[0].toUpperCase() + colorwayGroupName.slice(1);
+ let defaultFluentId = `extension-colorways-${intensity}-name`;
+ let fluentId =
+ updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId;
+ [formattedMessage] = l10n.formatMessagesSync([
+ {
+ id: fluentId,
+ args: {
+ "colorway-name": colorwayGroupName,
+ },
+ },
+ ]);
+ } else {
+ let defaultFluentId = `extension-${addonIdPrefix}-${aProp}`;
+ let fluentId =
+ updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId;
+ [formattedMessage] = l10n.formatMessagesSync([{ id: fluentId }]);
+ }
+
+ return formattedMessage.value;
+ }
+
+ let [result, usedRepository] = chooseValue(
+ addon,
+ addon.selectedLocale,
+ aProp
+ );
+
+ if (result == null) {
+ // Legacy add-ons may be partially localized. Fall back to the default
+ // locale ensure that the result is a string where possible.
+ [result, usedRepository] = chooseValue(addon, addon.defaultLocale, aProp);
+ }
+
+ if (result && !usedRepository && aProp == "creator") {
+ return new lazy.AddonManagerPrivate.AddonAuthor(result);
+ }
+
+ return result;
+ });
+});
+
+["developers", "translators", "contributors"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+
+ let [results, usedRepository] = chooseValue(
+ addon,
+ addon.selectedLocale,
+ aProp
+ );
+
+ if (results && !usedRepository) {
+ results = results.map(function (aResult) {
+ return new lazy.AddonManagerPrivate.AddonAuthor(aResult);
+ });
+ }
+
+ return results;
+ });
+});
+
+/**
+ * @typedef {Map<string, AddonInternal>} AddonDB
+ */
+
+/**
+ * Internal interface: find an addon from an already loaded addonDB.
+ *
+ * @param {AddonDB} addonDB
+ * The add-on database.
+ * @param {function(AddonInternal) : boolean} aFilter
+ * The filter predecate. The first add-on for which it returns
+ * true will be returned.
+ * @returns {AddonInternal?}
+ * The first matching add-on, if one is found.
+ */
+function _findAddon(addonDB, aFilter) {
+ for (let addon of addonDB.values()) {
+ if (aFilter(addon)) {
+ return addon;
+ }
+ }
+ return null;
+}
+
+/**
+ * Internal interface to get a filtered list of addons from a loaded addonDB
+ *
+ * @param {AddonDB} addonDB
+ * The add-on database.
+ * @param {function(AddonInternal) : boolean} aFilter
+ * The filter predecate. Add-ons which match this predicate will
+ * be returned.
+ * @returns {Array<AddonInternal>}
+ * The list of matching add-ons.
+ */
+function _filterDB(addonDB, aFilter) {
+ return Array.from(addonDB.values()).filter(aFilter);
+}
+
+export const XPIDatabase = {
+ // true if the database connection has been opened
+ initialized: false,
+ // The database file
+ jsonFilePath: PathUtils.join(PathUtils.profileDir, FILE_JSON_DB),
+ rebuildingDatabase: false,
+ syncLoadingDB: false,
+ // Add-ons from the database in locations which are no longer
+ // supported.
+ orphanedAddons: [],
+
+ _saveTask: null,
+
+ // Saved error object if we fail to read an existing database
+ _loadError: null,
+
+ // Saved error object if we fail to save the database
+ _saveError: null,
+
+ // Error reported by our most recent attempt to read or write the database, if any
+ get lastError() {
+ if (this._loadError) {
+ return this._loadError;
+ }
+ if (this._saveError) {
+ return this._saveError;
+ }
+ return null;
+ },
+
+ async _saveNow() {
+ try {
+ await IOUtils.writeJSON(this.jsonFilePath, this, {
+ tmpPath: `${this.jsonFilePath}.tmp`,
+ });
+
+ if (!this._schemaVersionSet) {
+ // Update the XPIDB schema version preference the first time we
+ // successfully save the database.
+ logger.debug(
+ "XPI Database saved, setting schema version preference to " +
+ XPIExports.XPIInternal.DB_SCHEMA
+ );
+ Services.prefs.setIntPref(
+ PREF_DB_SCHEMA,
+ XPIExports.XPIInternal.DB_SCHEMA
+ );
+ this._schemaVersionSet = true;
+
+ // Reading the DB worked once, so we don't need the load error
+ this._loadError = null;
+ }
+ } catch (error) {
+ logger.warn("Failed to save XPI database", error);
+ this._saveError = error;
+
+ if (!DOMException.isInstance(error) || error.name !== "AbortError") {
+ throw error;
+ }
+ }
+ },
+
+ /**
+ * Mark the current stored data dirty, and schedule a flush to disk
+ */
+ saveChanges() {
+ if (!this.initialized) {
+ throw new Error("Attempt to use XPI database when it is not initialized");
+ }
+
+ if (XPIExports.XPIProvider._closing) {
+ // use an Error here so we get a stack trace.
+ let err = new Error("XPI database modified after shutdown began");
+ logger.warn(err);
+ lazy.AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_late_stack",
+ Log.stackTrace(err)
+ );
+ }
+
+ if (!this._saveTask) {
+ this._saveTask = new lazy.DeferredTask(
+ () => this._saveNow(),
+ ASYNC_SAVE_DELAY_MS
+ );
+ }
+
+ this._saveTask.arm();
+ },
+
+ async finalize() {
+ // handle the "in memory only" and "saveChanges never called" cases
+ if (!this._saveTask) {
+ return;
+ }
+
+ await this._saveTask.finalize();
+ },
+
+ /**
+ * Converts the current internal state of the XPI addon database to
+ * a JSON.stringify()-ready structure
+ *
+ * @returns {Object}
+ */
+ toJSON() {
+ if (!this.addonDB) {
+ // We never loaded the database?
+ throw new Error("Attempt to save database without loading it first");
+ }
+
+ let toSave = {
+ schemaVersion: XPIExports.XPIInternal.DB_SCHEMA,
+ addons: Array.from(this.addonDB.values()).filter(
+ addon => !addon.location.isTemporary
+ ),
+ };
+ return toSave;
+ },
+
+ /**
+ * Synchronously loads the database, by running the normal async load
+ * operation with idle dispatch disabled, and spinning the event loop
+ * until it finishes.
+ *
+ * @param {boolean} aRebuildOnError
+ * A boolean indicating whether add-on information should be loaded
+ * from the install locations if the database needs to be rebuilt.
+ * (if false, caller is XPIProvider.checkForChanges() which will rebuild)
+ */
+ syncLoadDB(aRebuildOnError) {
+ let err = new Error("Synchronously loading the add-ons database");
+ logger.debug(err.message);
+ lazy.AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_sync_stack",
+ Log.stackTrace(err)
+ );
+ try {
+ this.syncLoadingDB = true;
+ XPIExports.XPIInternal.awaitPromise(this.asyncLoadDB(aRebuildOnError));
+ } finally {
+ this.syncLoadingDB = false;
+ }
+ },
+
+ _recordStartupError(reason) {
+ lazy.AddonManagerPrivate.recordSimpleMeasure("XPIDB_startupError", reason);
+ },
+
+ /**
+ * Parse loaded data, reconstructing the database if the loaded data is not valid
+ *
+ * @param {object} aInputAddons
+ * The add-on JSON to parse.
+ * @param {boolean} aRebuildOnError
+ * If true, synchronously reconstruct the database from installed add-ons
+ */
+ async parseDB(aInputAddons, aRebuildOnError) {
+ try {
+ let parseTimer = lazy.AddonManagerPrivate.simpleTimer("XPIDB_parseDB_MS");
+
+ if (!("schemaVersion" in aInputAddons) || !("addons" in aInputAddons)) {
+ let error = new Error("Bad JSON file contents");
+ error.rebuildReason = "XPIDB_rebuildBadJSON_MS";
+ throw error;
+ }
+
+ if (aInputAddons.schemaVersion <= 27) {
+ // Types were translated in bug 857456.
+ for (let addon of aInputAddons.addons) {
+ XPIExports.XPIInternal.migrateAddonLoader(addon);
+ }
+ } else if (
+ aInputAddons.schemaVersion != XPIExports.XPIInternal.DB_SCHEMA
+ ) {
+ // For now, we assume compatibility for JSON data with a
+ // mismatched schema version, though we throw away any fields we
+ // don't know about (bug 902956)
+ this._recordStartupError(
+ `schemaMismatch-${aInputAddons.schemaVersion}`
+ );
+ logger.debug(
+ `JSON schema mismatch: expected ${XPIExports.XPIInternal.DB_SCHEMA}, actual ${aInputAddons.schemaVersion}`
+ );
+ }
+
+ let forEach = this.syncLoadingDB ? arrayForEach : idleForEach;
+
+ // If we got here, we probably have good data
+ // Make AddonInternal instances from the loaded data and save them
+ let addonDB = new Map();
+ await forEach(aInputAddons.addons, loadedAddon => {
+ if (loadedAddon.path) {
+ try {
+ loadedAddon._sourceBundle = new nsIFile(loadedAddon.path);
+ } catch (e) {
+ // We can fail here when the path is invalid, usually from the
+ // wrong OS
+ logger.warn(
+ "Could not find source bundle for add-on " + loadedAddon.id,
+ e
+ );
+ }
+ }
+ loadedAddon.location = XPIExports.XPIInternal.XPIStates.getLocation(
+ loadedAddon.location
+ );
+
+ let newAddon = new AddonInternal(loadedAddon);
+ if (loadedAddon.location) {
+ addonDB.set(newAddon._key, newAddon);
+ } else {
+ this.orphanedAddons.push(newAddon);
+ }
+ });
+
+ parseTimer.done();
+ this.addonDB = addonDB;
+ logger.debug("Successfully read XPI database");
+ this.initialized = true;
+ } catch (e) {
+ if (e.name == "SyntaxError") {
+ logger.error("Syntax error parsing saved XPI JSON data");
+ this._recordStartupError("syntax");
+ } else {
+ logger.error("Failed to load XPI JSON data from profile", e);
+ this._recordStartupError("other");
+ }
+
+ this.timeRebuildDatabase(
+ e.rebuildReason || "XPIDB_rebuildReadFailed_MS",
+ aRebuildOnError
+ );
+ }
+ },
+
+ async maybeIdleDispatch() {
+ if (!this.syncLoadingDB) {
+ await promiseIdleSlice();
+ }
+ },
+
+ /**
+ * Open and read the XPI database asynchronously, upgrading if
+ * necessary. If any DB load operation fails, we need to
+ * synchronously rebuild the DB from the installed extensions.
+ *
+ * @param {boolean} [aRebuildOnError = true]
+ * A boolean indicating whether add-on information should be loaded
+ * from the install locations if the database needs to be rebuilt.
+ * (if false, caller is XPIProvider.checkForChanges() which will rebuild)
+ * @returns {Promise<AddonDB>}
+ * Resolves to the Map of loaded JSON data stored in
+ * this.addonDB; rejects in case of shutdown.
+ */
+ asyncLoadDB(aRebuildOnError = true) {
+ // Already started (and possibly finished) loading
+ if (this._dbPromise) {
+ return this._dbPromise;
+ }
+
+ if (XPIExports.XPIProvider._closing) {
+ // use an Error here so we get a stack trace.
+ let err = new Error(
+ "XPIDatabase.asyncLoadDB attempt after XPIProvider shutdown."
+ );
+ logger.warn("Fail to load AddonDB: ${error}", { error: err });
+ lazy.AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_late_load",
+ Log.stackTrace(err)
+ );
+ this._dbPromise = Promise.reject(err);
+
+ XPIExports.XPIInternal.resolveDBReady(this._dbPromise);
+
+ return this._dbPromise;
+ }
+
+ logger.debug(`Starting async load of XPI database ${this.jsonFilePath}`);
+ this._dbPromise = (async () => {
+ try {
+ let json = await IOUtils.readJSON(this.jsonFilePath);
+
+ logger.debug("Finished async read of XPI database, parsing...");
+ await this.maybeIdleDispatch();
+ await this.parseDB(json, true);
+ } catch (error) {
+ if (DOMException.isInstance(error) && error.name === "NotFoundError") {
+ if (Services.prefs.getIntPref(PREF_DB_SCHEMA, 0)) {
+ this._recordStartupError("dbMissing");
+ }
+ } else {
+ logger.warn(
+ `Extensions database ${this.jsonFilePath} exists but is not readable; rebuilding`,
+ error
+ );
+ this._loadError = error;
+ }
+ this.timeRebuildDatabase(
+ "XPIDB_rebuildUnreadableDB_MS",
+ aRebuildOnError
+ );
+ }
+ return this.addonDB;
+ })();
+
+ XPIExports.XPIInternal.resolveDBReady(this._dbPromise);
+
+ return this._dbPromise;
+ },
+
+ timeRebuildDatabase(timerName, rebuildOnError) {
+ lazy.AddonManagerPrivate.recordTiming(timerName, () => {
+ return this.rebuildDatabase(rebuildOnError);
+ });
+ },
+
+ /**
+ * Rebuild the database from addon install directories.
+ *
+ * @param {boolean} aRebuildOnError
+ * A boolean indicating whether add-on information should be loaded
+ * from the install locations if the database needs to be rebuilt.
+ * (if false, caller is XPIProvider.checkForChanges() which will rebuild)
+ */
+ rebuildDatabase(aRebuildOnError) {
+ this.addonDB = new Map();
+ this.initialized = true;
+
+ if (XPIExports.XPIInternal.XPIStates.size == 0) {
+ // No extensions installed, so we're done
+ logger.debug("Rebuilding XPI database with no extensions");
+ return;
+ }
+
+ this.rebuildingDatabase = !!aRebuildOnError;
+
+ if (aRebuildOnError) {
+ logger.warn("Rebuilding add-ons database from installed extensions.");
+ try {
+ XPIDatabaseReconcile.processFileChanges({}, false);
+ } catch (e) {
+ logger.error(
+ "Failed to rebuild XPI database from installed extensions",
+ e
+ );
+ }
+ // Make sure to update the active add-ons and add-ons list on shutdown
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+ }
+ },
+
+ /**
+ * Shuts down the database connection and releases all cached objects.
+ * Return: Promise{integer} resolves / rejects with the result of the DB
+ * flush after the database is flushed and
+ * all cleanup is done
+ */
+ async shutdown() {
+ logger.debug("shutdown");
+ if (this.initialized) {
+ // If our last database I/O had an error, try one last time to save.
+ if (this.lastError) {
+ this.saveChanges();
+ }
+
+ this.initialized = false;
+
+ // If we're shutting down while still loading, finish loading
+ // before everything else!
+ if (this._dbPromise) {
+ await this._dbPromise;
+ }
+
+ // Await any pending DB writes and finish cleaning up.
+ await this.finalize();
+
+ if (this._saveError) {
+ // If our last attempt to read or write the DB failed, force a new
+ // extensions.ini to be written to disk on the next startup
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+ }
+
+ // Clear out the cached addons data loaded from JSON
+ delete this.addonDB;
+ delete this._dbPromise;
+ // same for the deferred save
+ delete this._saveTask;
+ // re-enable the schema version setter
+ delete this._schemaVersionSet;
+ }
+ },
+
+ /**
+ * Verifies that all installed add-ons are still correctly signed.
+ */
+ async verifySignatures() {
+ try {
+ let addons = await this.getAddonList(a => true);
+
+ let changes = {
+ enabled: [],
+ disabled: [],
+ };
+
+ for (let addon of addons) {
+ // The add-on might have vanished, we'll catch that on the next startup
+ if (!addon._sourceBundle || !addon._sourceBundle.exists()) {
+ continue;
+ }
+
+ let signedState = await XPIExports.verifyBundleSignedState(
+ addon._sourceBundle,
+ addon
+ );
+
+ if (signedState != addon.signedState) {
+ addon.signedState = signedState;
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onPropertyChanged",
+ addon.wrapper,
+ ["signedState"]
+ );
+ }
+
+ let disabled = await this.updateAddonDisabledState(addon);
+ if (disabled !== undefined) {
+ changes[disabled ? "disabled" : "enabled"].push(addon.id);
+ }
+ }
+
+ this.saveChanges();
+
+ Services.obs.notifyObservers(
+ null,
+ "xpi-signature-changed",
+ JSON.stringify(changes)
+ );
+ } catch (err) {
+ logger.error("XPI_verifySignature: " + err);
+ }
+ },
+
+ /**
+ * Imports the xpinstall permissions from preferences into the permissions
+ * manager for the user to change later.
+ */
+ importPermissions() {
+ lazy.PermissionsUtils.importFromPrefs(
+ PREF_XPI_PERMISSIONS_BRANCH,
+ XPIExports.XPIInternal.XPI_PERMISSION
+ );
+ },
+
+ /**
+ * Called when a new add-on has been enabled when only one add-on of that type
+ * can be enabled.
+ *
+ * @param {string} aId
+ * The ID of the newly enabled add-on
+ * @param {string} aType
+ * The type of the newly enabled add-on
+ */
+ async addonChanged(aId, aType) {
+ // We only care about themes in this provider
+ if (aType !== "theme") {
+ return;
+ }
+
+ Services.prefs.setCharPref(
+ "extensions.activeThemeID",
+ aId || DEFAULT_THEME_ID
+ );
+
+ let enableTheme;
+
+ let addons = this.getAddonsByType("theme");
+ let updateDisabledStatePromises = [];
+
+ for (let theme of addons) {
+ if (theme.visible) {
+ if (!aId && theme.id == DEFAULT_THEME_ID) {
+ enableTheme = theme;
+ } else if (theme.id != aId && !theme.pendingUninstall) {
+ updateDisabledStatePromises.push(
+ this.updateAddonDisabledState(theme, {
+ userDisabled: true,
+ becauseSelecting: true,
+ })
+ );
+ }
+ }
+ }
+
+ await Promise.all(updateDisabledStatePromises);
+
+ if (enableTheme) {
+ await this.updateAddonDisabledState(enableTheme, {
+ userDisabled: false,
+ becauseSelecting: true,
+ });
+ }
+ },
+
+ SIGNED_TYPES,
+
+ /**
+ * Asynchronously list all addons that match the filter function
+ *
+ * @param {function(AddonInternal) : boolean} aFilter
+ * Function that takes an addon instance and returns
+ * true if that addon should be included in the selected array
+ *
+ * @returns {Array<AddonInternal>}
+ * A Promise that resolves to the list of add-ons matching
+ * aFilter or an empty array if none match
+ */
+ async getAddonList(aFilter) {
+ try {
+ let addonDB = await this.asyncLoadDB();
+ let addonList = _filterDB(addonDB, aFilter);
+ let addons = await Promise.all(
+ addonList.map(addon => getRepositoryAddon(addon))
+ );
+ return addons;
+ } catch (error) {
+ logger.error("getAddonList failed", error);
+ return [];
+ }
+ },
+
+ /**
+ * Get the first addon that matches the filter function
+ *
+ * @param {function(AddonInternal) : boolean} aFilter
+ * Function that takes an addon instance and returns
+ * true if that addon should be selected
+ * @returns {Promise<AddonInternal?>}
+ */
+ getAddon(aFilter) {
+ return this.asyncLoadDB()
+ .then(addonDB => getRepositoryAddon(_findAddon(addonDB, aFilter)))
+ .catch(error => {
+ logger.error("getAddon failed", error);
+ });
+ },
+
+ /**
+ * Asynchronously gets an add-on with a particular ID in a particular
+ * install location.
+ *
+ * @param {string} aId
+ * The ID of the add-on to retrieve
+ * @param {string} aLocation
+ * The name of the install location
+ * @returns {Promise<AddonInternal?>}
+ */
+ getAddonInLocation(aId, aLocation) {
+ return this.asyncLoadDB().then(addonDB =>
+ getRepositoryAddon(addonDB.get(aLocation + ":" + aId))
+ );
+ },
+
+ /**
+ * Asynchronously get all the add-ons in a particular install location.
+ *
+ * @param {string} aLocation
+ * The name of the install location
+ * @returns {Promise<Array<AddonInternal>>}
+ */
+ getAddonsInLocation(aLocation) {
+ return this.getAddonList(aAddon => aAddon.location.name == aLocation);
+ },
+
+ /**
+ * Asynchronously gets the add-on with the specified ID that is visible.
+ *
+ * @param {string} aId
+ * The ID of the add-on to retrieve
+ * @returns {Promise<AddonInternal?>}
+ */
+ getVisibleAddonForID(aId) {
+ return this.getAddon(aAddon => aAddon.id == aId && aAddon.visible);
+ },
+
+ /**
+ * Asynchronously gets the visible add-ons, optionally restricting by type.
+ *
+ * @param {Set<string>?} aTypes
+ * An array of types to include or null to include all types
+ * @returns {Promise<Array<AddonInternal>>}
+ */
+ getVisibleAddons(aTypes) {
+ return this.getAddonList(
+ aAddon => aAddon.visible && (!aTypes || aTypes.has(aAddon.type))
+ );
+ },
+
+ /**
+ * Synchronously gets all add-ons of a particular type(s).
+ *
+ * @param {Array<string>} aTypes
+ * The type(s) of add-on to retrieve
+ * @returns {Array<AddonInternal>}
+ */
+ getAddonsByType(...aTypes) {
+ if (!this.addonDB) {
+ // jank-tastic! Must synchronously load DB if the theme switches from
+ // an XPI theme to a lightweight theme before the DB has loaded,
+ // because we're called from sync XPIProvider.addonChanged
+ logger.warn(
+ `Synchronous load of XPI database due to ` +
+ `getAddonsByType([${aTypes.join(", ")}]) ` +
+ `Stack: ${Error().stack}`
+ );
+ this.syncLoadDB(true);
+ }
+
+ return _filterDB(this.addonDB, aAddon => aTypes.includes(aAddon.type));
+ },
+
+ /**
+ * Asynchronously gets all add-ons with pending operations.
+ *
+ * @param {Set<string>?} aTypes
+ * The types of add-ons to retrieve or null to get all types
+ * @returns {Promise<Array<AddonInternal>>}
+ */
+ getVisibleAddonsWithPendingOperations(aTypes) {
+ return this.getAddonList(
+ aAddon =>
+ aAddon.visible &&
+ aAddon.pendingUninstall &&
+ (!aTypes || aTypes.has(aAddon.type))
+ );
+ },
+
+ /**
+ * Synchronously gets all add-ons in the database.
+ * This is only called from the preference observer for the default
+ * compatibility version preference, so we can return an empty list if
+ * we haven't loaded the database yet.
+ *
+ * @returns {Array<AddonInternal>}
+ */
+ getAddons() {
+ if (!this.addonDB) {
+ return [];
+ }
+ return _filterDB(this.addonDB, aAddon => true);
+ },
+
+ /**
+ * Called to get an Addon with a particular ID.
+ *
+ * @param {string} aId
+ * The ID of the add-on to retrieve
+ * @returns {Addon?}
+ */
+ async getAddonByID(aId) {
+ let aAddon = await this.getVisibleAddonForID(aId);
+ return aAddon ? aAddon.wrapper : null;
+ },
+
+ /**
+ * Obtain an Addon having the specified Sync GUID.
+ *
+ * @param {string} aGUID
+ * String GUID of add-on to retrieve
+ * @returns {Addon?}
+ */
+ async getAddonBySyncGUID(aGUID) {
+ let addon = await this.getAddon(aAddon => aAddon.syncGUID == aGUID);
+ return addon ? addon.wrapper : null;
+ },
+
+ /**
+ * Called to get Addons of a particular type.
+ *
+ * @param {Array<string>?} aTypes
+ * An array of types to fetch. Can be null to get all types.
+ * @returns {Addon[]}
+ */
+ async getAddonsByTypes(aTypes) {
+ let addons = await this.getVisibleAddons(aTypes ? new Set(aTypes) : null);
+ return addons.map(a => a.wrapper);
+ },
+
+ /**
+ * Returns true if signing is required for the given add-on type.
+ *
+ * @param {string} aType
+ * The add-on type to check.
+ * @returns {boolean}
+ */
+ mustSign(aType) {
+ if (!SIGNED_TYPES.has(aType)) {
+ return false;
+ }
+
+ if (aType == "locale") {
+ return lazy.AddonSettings.LANGPACKS_REQUIRE_SIGNING;
+ }
+
+ return lazy.AddonSettings.REQUIRE_SIGNING;
+ },
+
+ /**
+ * Determine if this addon should be disabled due to being legacy
+ *
+ * @param {Addon} addon The addon to check
+ *
+ * @returns {boolean} Whether the addon should be disabled for being legacy
+ */
+ isDisabledLegacy(addon) {
+ // We still have tests that use a legacy addon type, allow them
+ // if we're in automation. Otherwise, disable if not a webextension.
+ if (!Cu.isInAutomation) {
+ return !addon.isWebExtension;
+ }
+
+ return (
+ !addon.isWebExtension &&
+ addon.type === "extension" &&
+ // Test addons are privileged unless forced otherwise.
+ addon.signedState !== lazy.AddonManager.SIGNEDSTATE_PRIVILEGED
+ );
+ },
+
+ /**
+ * Calculates whether an add-on should be appDisabled or not.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to check
+ * @returns {boolean}
+ * True if the add-on should not be appDisabled
+ */
+ isUsableAddon(aAddon) {
+ if (this.mustSign(aAddon.type) && !aAddon.isCorrectlySigned) {
+ logger.warn(`Add-on ${aAddon.id} is not correctly signed.`);
+ if (Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)) {
+ logger.warn(`Preference ${PREF_XPI_SIGNATURES_DEV_ROOT} is set.`);
+ }
+ return false;
+ }
+
+ if (aAddon.blocklistState == nsIBlocklistService.STATE_BLOCKED) {
+ logger.warn(`Add-on ${aAddon.id} is blocklisted.`);
+ return false;
+ }
+
+ // If we can't read it, it's not usable:
+ if (aAddon.brokenManifest) {
+ return false;
+ }
+
+ if (
+ lazy.AddonManager.checkUpdateSecurity &&
+ !aAddon.providesUpdatesSecurely
+ ) {
+ logger.warn(
+ `Updates for add-on ${aAddon.id} must be provided over HTTPS.`
+ );
+ return false;
+ }
+
+ if (!aAddon.isPlatformCompatible) {
+ logger.warn(`Add-on ${aAddon.id} is not compatible with platform.`);
+ return false;
+ }
+
+ if (aAddon.dependencies.length) {
+ let isActive = id => {
+ let active = XPIExports.XPIProvider.activeAddons.get(id);
+ return active && !active._pendingDisable;
+ };
+
+ if (aAddon.dependencies.some(id => !isActive(id))) {
+ return false;
+ }
+ }
+
+ if (this.isDisabledLegacy(aAddon)) {
+ logger.warn(`disabling legacy extension ${aAddon.id}`);
+ return false;
+ }
+
+ if (lazy.AddonManager.checkCompatibility) {
+ if (!aAddon.isCompatible) {
+ logger.warn(
+ `Add-on ${aAddon.id} is not compatible with application version.`
+ );
+ return false;
+ }
+ } else {
+ let app = aAddon.matchingTargetApplication;
+ if (!app) {
+ logger.warn(
+ `Add-on ${aAddon.id} is not compatible with target application.`
+ );
+ return false;
+ }
+ }
+
+ if (aAddon.location.isSystem || aAddon.location.isBuiltin) {
+ return true;
+ }
+
+ if (Services.policies && !Services.policies.mayInstallAddon(aAddon)) {
+ return false;
+ }
+
+ return true;
+ },
+
+ /**
+ * Synchronously adds an AddonInternal's metadata to the database.
+ *
+ * @param {AddonInternal} aAddon
+ * AddonInternal to add
+ * @param {string} aPath
+ * The file path of the add-on
+ * @returns {AddonInternal}
+ * the AddonInternal that was added to the database
+ */
+ addToDatabase(aAddon, aPath) {
+ aAddon.addedToDatabase();
+ aAddon.path = aPath;
+ this.addonDB.set(aAddon._key, aAddon);
+ if (aAddon.visible) {
+ this.makeAddonVisible(aAddon);
+ }
+
+ this.saveChanges();
+ return aAddon;
+ },
+
+ /**
+ * Synchronously updates an add-on's metadata in the database. Currently just
+ * removes and recreates.
+ *
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal to be replaced
+ * @param {AddonInternal} aNewAddon
+ * The new AddonInternal to add
+ * @param {string} aPath
+ * The file path of the add-on
+ * @returns {AddonInternal}
+ * The AddonInternal that was added to the database
+ */
+ updateAddonMetadata(aOldAddon, aNewAddon, aPath) {
+ this.removeAddonMetadata(aOldAddon);
+ aNewAddon.syncGUID = aOldAddon.syncGUID;
+ aNewAddon.installDate = aOldAddon.installDate;
+ aNewAddon.applyBackgroundUpdates = aOldAddon.applyBackgroundUpdates;
+ aNewAddon.foreignInstall = aOldAddon.foreignInstall;
+ aNewAddon.seen = aOldAddon.seen;
+ aNewAddon.active =
+ aNewAddon.visible && !aNewAddon.disabled && !aNewAddon.pendingUninstall;
+ aNewAddon.installTelemetryInfo = aOldAddon.installTelemetryInfo;
+
+ return this.addToDatabase(aNewAddon, aPath);
+ },
+
+ /**
+ * Synchronously removes an add-on from the database.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal being removed
+ */
+ removeAddonMetadata(aAddon) {
+ this.addonDB.delete(aAddon._key);
+ this.saveChanges();
+ },
+
+ updateXPIStates(addon) {
+ let state = addon.location && addon.location.get(addon.id);
+ if (state) {
+ state.syncWithDB(addon);
+ XPIExports.XPIInternal.XPIStates.save();
+ }
+ },
+
+ /**
+ * Synchronously marks a AddonInternal as visible marking all other
+ * instances with the same ID as not visible.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal to make visible
+ */
+ makeAddonVisible(aAddon) {
+ logger.debug("Make addon " + aAddon._key + " visible");
+ for (let [, otherAddon] of this.addonDB) {
+ if (otherAddon.id == aAddon.id && otherAddon._key != aAddon._key) {
+ logger.debug("Hide addon " + otherAddon._key);
+ otherAddon.visible = false;
+ otherAddon.active = false;
+
+ this.updateXPIStates(otherAddon);
+ }
+ }
+ aAddon.visible = true;
+ this.updateXPIStates(aAddon);
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously marks a given add-on ID visible in a given location,
+ * instances with the same ID as not visible.
+ *
+ * @param {string} aId
+ * The ID of the add-on to make visible
+ * @param {XPIStateLocation} aLocation
+ * The location in which to make the add-on visible.
+ * @returns {AddonInternal?}
+ * The add-on instance which was marked visible, if any.
+ */
+ makeAddonLocationVisible(aId, aLocation) {
+ logger.debug(`Make addon ${aId} visible in location ${aLocation}`);
+ let result;
+ for (let [, addon] of this.addonDB) {
+ if (addon.id != aId) {
+ continue;
+ }
+ if (addon.location == aLocation) {
+ logger.debug("Reveal addon " + addon._key);
+ addon.visible = true;
+ addon.active = true;
+ this.updateXPIStates(addon);
+ result = addon;
+ } else {
+ logger.debug("Hide addon " + addon._key);
+ addon.visible = false;
+ addon.active = false;
+ this.updateXPIStates(addon);
+ }
+ }
+ this.saveChanges();
+ return result;
+ },
+
+ /**
+ * Synchronously sets properties for an add-on.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal being updated
+ * @param {Object} aProperties
+ * A dictionary of properties to set
+ */
+ setAddonProperties(aAddon, aProperties) {
+ for (let key in aProperties) {
+ aAddon[key] = aProperties[key];
+ }
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously sets the Sync GUID for an add-on.
+ * Only called when the database is already loaded.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal being updated
+ * @param {string} aGUID
+ * GUID string to set the value to
+ * @throws if another addon already has the specified GUID
+ */
+ setAddonSyncGUID(aAddon, aGUID) {
+ // Need to make sure no other addon has this GUID
+ function excludeSyncGUID(otherAddon) {
+ return otherAddon._key != aAddon._key && otherAddon.syncGUID == aGUID;
+ }
+ let otherAddon = _findAddon(this.addonDB, excludeSyncGUID);
+ if (otherAddon) {
+ throw new Error(
+ "Addon sync GUID conflict for addon " +
+ aAddon._key +
+ ": " +
+ otherAddon._key +
+ " already has GUID " +
+ aGUID
+ );
+ }
+ aAddon.syncGUID = aGUID;
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously updates an add-on's active flag in the database.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal to update
+ * @param {boolean} aActive
+ * The new active state for the add-on.
+ */
+ updateAddonActive(aAddon, aActive) {
+ logger.debug(
+ "Updating active state for add-on " + aAddon.id + " to " + aActive
+ );
+
+ aAddon.active = aActive;
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously calculates and updates all the active flags in the database.
+ */
+ updateActiveAddons() {
+ logger.debug("Updating add-on states");
+ for (let [, addon] of this.addonDB) {
+ let newActive =
+ addon.visible && !addon.disabled && !addon.pendingUninstall;
+ if (newActive != addon.active) {
+ addon.active = newActive;
+ this.saveChanges();
+ }
+ }
+
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, false);
+ },
+
+ /**
+ * Updates the disabled state for an add-on. Its appDisabled property will be
+ * calculated and if the add-on is changed the database will be saved and
+ * appropriate notifications will be sent out to the registered AddonListeners.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal to update
+ * @param {Object} properties - Properties to set on the addon
+ * @param {boolean?} [properties.userDisabled]
+ * Value for the userDisabled property. If undefined the value will
+ * not change
+ * @param {boolean?} [properties.softDisabled]
+ * Value for the softDisabled property. If undefined the value will
+ * not change. If true this will force userDisabled to be true
+ * @param {boolean?} [properties.embedderDisabled]
+ * Value for the embedderDisabled property. If undefined the value will
+ * not change.
+ * @param {boolean?} [properties.becauseSelecting]
+ * True if we're disabling this add-on because we're selecting
+ * another.
+ * @returns {Promise<boolean?>}
+ * A tri-state indicating the action taken for the add-on:
+ * - undefined: The add-on did not change state
+ * - true: The add-on became disabled
+ * - false: The add-on became enabled
+ * @throws if addon is not a AddonInternal
+ */
+ async updateAddonDisabledState(
+ aAddon,
+ { userDisabled, softDisabled, embedderDisabled, becauseSelecting } = {}
+ ) {
+ if (!aAddon.inDatabase) {
+ throw new Error("Can only update addon states for installed addons.");
+ }
+ if (userDisabled !== undefined && softDisabled !== undefined) {
+ throw new Error(
+ "Cannot change userDisabled and softDisabled at the same time"
+ );
+ }
+
+ if (userDisabled === undefined) {
+ userDisabled = aAddon.userDisabled;
+ } else if (!userDisabled) {
+ // If enabling the add-on then remove softDisabled
+ softDisabled = false;
+ }
+
+ // If not changing softDisabled or the add-on is already userDisabled then
+ // use the existing value for softDisabled
+ if (softDisabled === undefined || userDisabled) {
+ softDisabled = aAddon.softDisabled;
+ }
+
+ if (!lazy.AddonSettings.IS_EMBEDDED) {
+ // If embedderDisabled was accidentally set somehow, this will revert it
+ // back to false.
+ embedderDisabled = false;
+ } else if (embedderDisabled === undefined) {
+ embedderDisabled = aAddon.embedderDisabled;
+ }
+
+ let appDisabled = !this.isUsableAddon(aAddon);
+ // No change means nothing to do here
+ if (
+ aAddon.userDisabled == userDisabled &&
+ aAddon.appDisabled == appDisabled &&
+ aAddon.softDisabled == softDisabled &&
+ aAddon.embedderDisabled == embedderDisabled
+ ) {
+ return undefined;
+ }
+
+ let wasDisabled = aAddon.disabled;
+ let isDisabled =
+ userDisabled || softDisabled || appDisabled || embedderDisabled;
+
+ // If appDisabled changes but addon.disabled doesn't,
+ // no onDisabling/onEnabling is sent - so send a onPropertyChanged.
+ let appDisabledChanged = aAddon.appDisabled != appDisabled;
+
+ // Update the properties in the database.
+ this.setAddonProperties(aAddon, {
+ userDisabled,
+ appDisabled,
+ softDisabled,
+ embedderDisabled,
+ });
+
+ let wrapper = aAddon.wrapper;
+
+ if (appDisabledChanged) {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onPropertyChanged",
+ wrapper,
+ ["appDisabled"]
+ );
+ }
+
+ // If the add-on is not visible or the add-on is not changing state then
+ // there is no need to do anything else
+ if (!aAddon.visible || wasDisabled == isDisabled) {
+ return undefined;
+ }
+
+ // Flag that active states in the database need to be updated on shutdown
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+
+ this.updateXPIStates(aAddon);
+
+ // Have we just gone back to the current state?
+ if (isDisabled != aAddon.active) {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onOperationCancelled",
+ wrapper
+ );
+ } else {
+ if (isDisabled) {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onDisabling",
+ wrapper,
+ false
+ );
+ } else {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onEnabling",
+ wrapper,
+ false
+ );
+ }
+
+ this.updateAddonActive(aAddon, !isDisabled);
+
+ let bootstrap = XPIExports.XPIInternal.BootstrapScope.get(aAddon);
+ if (isDisabled) {
+ await bootstrap.disable();
+ lazy.AddonManagerPrivate.callAddonListeners("onDisabled", wrapper);
+ } else {
+ await bootstrap.startup(
+ XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_ENABLE
+ );
+ lazy.AddonManagerPrivate.callAddonListeners("onEnabled", wrapper);
+ }
+ }
+
+ // Notify any other providers that a new theme has been enabled
+ if (aAddon.type === "theme") {
+ if (!isDisabled) {
+ await lazy.AddonManagerPrivate.notifyAddonChanged(
+ aAddon.id,
+ aAddon.type
+ );
+ } else if (isDisabled && !becauseSelecting) {
+ await lazy.AddonManagerPrivate.notifyAddonChanged(null, "theme");
+ }
+ }
+
+ return isDisabled;
+ },
+
+ /**
+ * Update the appDisabled property for all add-ons.
+ */
+ updateAddonAppDisabledStates() {
+ for (let addon of this.getAddons()) {
+ this.updateAddonDisabledState(addon);
+ }
+ },
+
+ /**
+ * Update the repositoryAddon property for all add-ons.
+ */
+ async updateAddonRepositoryData() {
+ let addons = await this.getVisibleAddons(null);
+ logger.debug(
+ "updateAddonRepositoryData found " + addons.length + " visible add-ons"
+ );
+
+ await Promise.all(
+ addons.map(addon =>
+ lazy.AddonRepository.getCachedAddonByID(addon.id).then(aRepoAddon => {
+ if (aRepoAddon) {
+ logger.debug("updateAddonRepositoryData got info for " + addon.id);
+ addon._repositoryAddon = aRepoAddon;
+ return this.updateAddonDisabledState(addon);
+ }
+ return undefined;
+ })
+ )
+ );
+ },
+
+ /**
+ * Adds the add-on's name and creator to the telemetry payload.
+ *
+ * @param {AddonInternal} aAddon
+ * The addon to record
+ */
+ recordAddonTelemetry(aAddon) {
+ let locale = aAddon.defaultLocale;
+ XPIExports.XPIProvider.addTelemetry(aAddon.id, {
+ name: locale.name,
+ creator: locale.creator,
+ });
+ },
+};
+
+export const XPIDatabaseReconcile = {
+ /**
+ * Returns a map of ID -> add-on. When the same add-on ID exists in multiple
+ * install locations the highest priority location is chosen.
+ *
+ * @param {Map<String, AddonInternal>} addonMap
+ * The add-on map to flatten.
+ * @param {string?} [hideLocation]
+ * An optional location from which to hide any add-ons.
+ * @returns {Map<string, AddonInternal>}
+ */
+ flattenByID(addonMap, hideLocation) {
+ let map = new Map();
+
+ for (let loc of XPIExports.XPIInternal.XPIStates.locations()) {
+ if (loc.name == hideLocation) {
+ continue;
+ }
+
+ let locationMap = addonMap.get(loc.name);
+ if (!locationMap) {
+ continue;
+ }
+
+ for (let [id, addon] of locationMap) {
+ if (!map.has(id)) {
+ map.set(id, addon);
+ }
+ }
+ }
+
+ return map;
+ },
+
+ /**
+ * Finds the visible add-ons from the map.
+ *
+ * @param {Map<String, AddonInternal>} addonMap
+ * The add-on map to filter.
+ * @returns {Map<string, AddonInternal>}
+ */
+ getVisibleAddons(addonMap) {
+ let map = new Map();
+
+ for (let addons of addonMap.values()) {
+ for (let [id, addon] of addons) {
+ if (!addon.visible) {
+ continue;
+ }
+
+ if (map.has(id)) {
+ logger.warn(
+ "Previous database listed more than one visible add-on with id " +
+ id
+ );
+ continue;
+ }
+
+ map.set(id, addon);
+ }
+ }
+
+ return map;
+ },
+
+ /**
+ * Called to add the metadata for an add-on in one of the install locations
+ * to the database. This can be called in three different cases. Either an
+ * add-on has been dropped into the location from outside of Firefox, or
+ * an add-on has been installed through the application, or the database
+ * has been upgraded or become corrupt and add-on data has to be reloaded
+ * into it.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {string} aId
+ * The ID of the add-on
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @param {AddonInternal?} [aNewAddon]
+ * The manifest for the new add-on if it has already been loaded
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @returns {boolean}
+ * A boolean indicating if flushing caches is required to complete
+ * changing this add-on
+ */
+ addMetadata(
+ aLocation,
+ aId,
+ aAddonState,
+ aNewAddon,
+ aOldAppVersion,
+ aOldPlatformVersion
+ ) {
+ logger.debug(`New add-on ${aId} installed in ${aLocation.name}`);
+
+ // We treat this is a new install if,
+ //
+ // a) It was explicitly registered as a staged install in the last
+ // session, or,
+ // b) We're not currently migrating or rebuilding a corrupt database. In
+ // that case, we can assume this add-on was found during a routine
+ // directory scan.
+ let isNewInstall = !!aNewAddon || !XPIDatabase.rebuildingDatabase;
+
+ // If it's a new install and we haven't yet loaded the manifest then it
+ // must be something dropped directly into the install location
+ let isDetectedInstall = isNewInstall && !aNewAddon;
+
+ // Load the manifest if necessary and sanity check the add-on ID
+ let unsigned;
+ try {
+ // Do not allow third party installs if xpinstall is disabled by policy
+ if (
+ isDetectedInstall &&
+ Services.policies &&
+ !Services.policies.isAllowed("xpinstall")
+ ) {
+ throw new Error(
+ "Extension installs are disabled by enterprise policy."
+ );
+ }
+
+ if (!aNewAddon) {
+ // Load the manifest from the add-on.
+ aNewAddon = XPIExports.XPIInstall.syncLoadManifest(
+ aAddonState,
+ aLocation
+ );
+ }
+ // The add-on in the manifest should match the add-on ID.
+ if (aNewAddon.id != aId) {
+ throw new Error(
+ `Invalid addon ID: expected addon ID ${aId}, found ${aNewAddon.id} in manifest`
+ );
+ }
+
+ unsigned =
+ XPIDatabase.mustSign(aNewAddon.type) && !aNewAddon.isCorrectlySigned;
+ if (unsigned) {
+ throw Error(`Extension ${aNewAddon.id} is not correctly signed`);
+ }
+ } catch (e) {
+ logger.warn(`addMetadata: Add-on ${aId} is invalid`, e);
+
+ // Remove the invalid add-on from the install location if the install
+ // location isn't locked
+ if (aLocation.isLinkedAddon(aId)) {
+ logger.warn("Not uninstalling invalid item because it is a proxy file");
+ } else if (aLocation.locked) {
+ logger.warn(
+ "Could not uninstall invalid item from locked install location"
+ );
+ } else if (unsigned && !isNewInstall) {
+ logger.warn("Not uninstalling existing unsigned add-on");
+ } else if (aLocation.name == KEY_APP_BUILTINS) {
+ // If a builtin has been removed from the build, we need to remove it from our
+ // data sets. We cannot use location.isBuiltin since the system addon locations
+ // mix it up.
+ XPIDatabase.removeAddonMetadata(aAddonState);
+ aLocation.removeAddon(aId);
+ } else {
+ aLocation.installer.uninstallAddon(aId);
+ }
+ return null;
+ }
+
+ // Update the AddonInternal properties.
+ aNewAddon.installDate = aAddonState.mtime;
+ aNewAddon.updateDate = aAddonState.mtime;
+
+ // Assume that add-ons in the system add-ons install location aren't
+ // foreign and should default to enabled.
+ aNewAddon.foreignInstall =
+ isDetectedInstall && !aLocation.isSystem && !aLocation.isBuiltin;
+
+ // appDisabled depends on whether the add-on is a foreignInstall so update
+ aNewAddon.appDisabled = !XPIDatabase.isUsableAddon(aNewAddon);
+
+ if (isDetectedInstall && aNewAddon.foreignInstall) {
+ // Add the installation source info for the sideloaded extension.
+ aNewAddon.installTelemetryInfo = {
+ source: aLocation.name,
+ method: "sideload",
+ };
+
+ // If the add-on is a foreign install and is in a scope where add-ons
+ // that were dropped in should default to disabled then disable it
+ let disablingScopes = Services.prefs.getIntPref(
+ PREF_EM_AUTO_DISABLED_SCOPES,
+ 0
+ );
+ if (aLocation.scope & disablingScopes) {
+ logger.warn(
+ `Disabling foreign installed add-on ${aNewAddon.id} in ${aLocation.name}`
+ );
+ aNewAddon.userDisabled = true;
+ aNewAddon.seen = false;
+ }
+ }
+
+ return XPIDatabase.addToDatabase(aNewAddon, aAddonState.path);
+ },
+
+ /**
+ * Called when an add-on has been removed.
+ *
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ */
+ removeMetadata(aOldAddon) {
+ // This add-on has disappeared
+ logger.debug(
+ "Add-on " + aOldAddon.id + " removed from " + aOldAddon.location.name
+ );
+ XPIDatabase.removeAddonMetadata(aOldAddon);
+ },
+
+ /**
+ * Updates an add-on's metadata and determines. This is called when either the
+ * add-on's install directory path or last modified time has changed.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @param {AddonInternal?} [aNewAddon]
+ * The manifest for the new add-on if it has already been loaded
+ * @returns {AddonInternal}
+ * The AddonInternal that was added to the database
+ */
+ updateMetadata(aLocation, aOldAddon, aAddonState, aNewAddon) {
+ logger.debug(`Add-on ${aOldAddon.id} modified in ${aLocation.name}`);
+
+ try {
+ // If there isn't an updated install manifest for this add-on then load it.
+ if (!aNewAddon) {
+ aNewAddon = XPIExports.XPIInstall.syncLoadManifest(
+ aAddonState,
+ aLocation,
+ aOldAddon
+ );
+ } else {
+ aNewAddon.rootURI = aOldAddon.rootURI;
+ }
+
+ // The ID in the manifest that was loaded must match the ID of the old
+ // add-on.
+ if (aNewAddon.id != aOldAddon.id) {
+ throw new Error(
+ `Incorrect id in install manifest for existing add-on ${aOldAddon.id}`
+ );
+ }
+ } catch (e) {
+ logger.warn(`updateMetadata: Add-on ${aOldAddon.id} is invalid`, e);
+
+ XPIDatabase.removeAddonMetadata(aOldAddon);
+ aOldAddon.location.removeAddon(aOldAddon.id);
+
+ if (!aLocation.locked) {
+ aLocation.installer.uninstallAddon(aOldAddon.id);
+ } else {
+ logger.warn(
+ "Could not uninstall invalid item from locked install location"
+ );
+ }
+
+ return null;
+ }
+
+ // Set the additional properties on the new AddonInternal
+ aNewAddon.updateDate = aAddonState.mtime;
+
+ XPIExports.XPIProvider.persistStartupData(aNewAddon, aAddonState);
+
+ // Update the database
+ return XPIDatabase.updateAddonMetadata(
+ aOldAddon,
+ aNewAddon,
+ aAddonState.path
+ );
+ },
+
+ /**
+ * Updates an add-on's path for when the add-on has moved in the
+ * filesystem but hasn't changed in any other way.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @returns {AddonInternal}
+ */
+ updatePath(aLocation, aOldAddon, aAddonState) {
+ logger.debug(`Add-on ${aOldAddon.id} moved to ${aAddonState.path}`);
+ aOldAddon.path = aAddonState.path;
+ aOldAddon._sourceBundle = new nsIFile(aAddonState.path);
+ aOldAddon.rootURI = XPIExports.XPIInternal.getURIForResourceInFile(
+ aOldAddon._sourceBundle,
+ ""
+ ).spec;
+
+ return aOldAddon;
+ },
+
+ /**
+ * Called when no change has been detected for an add-on's metadata but the
+ * application has changed so compatibility may have changed.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @param {boolean} [aReloadMetadata = false]
+ * A boolean which indicates whether metadata should be reloaded from
+ * the addon manifests. Default to false.
+ * @returns {AddonInternal}
+ * The new addon.
+ */
+ updateCompatibility(aLocation, aOldAddon, aAddonState, aReloadMetadata) {
+ logger.debug(
+ `Updating compatibility for add-on ${aOldAddon.id} in ${aLocation.name}`
+ );
+
+ let checkSigning =
+ aOldAddon.signedState === undefined && SIGNED_TYPES.has(aOldAddon.type);
+ // signedDate must be set if signedState is set.
+ let signedDateMissing =
+ aOldAddon.signedDate === undefined &&
+ (aOldAddon.signedState || checkSigning);
+
+ // If maxVersion was inadvertently updated for a locale, force a reload
+ // from the manifest. See Bug 1646016 for details.
+ if (
+ !aReloadMetadata &&
+ aOldAddon.type === "locale" &&
+ aOldAddon.matchingTargetApplication
+ ) {
+ aReloadMetadata = aOldAddon.matchingTargetApplication.maxVersion === "*";
+ }
+
+ let manifest = null;
+ if (checkSigning || aReloadMetadata || signedDateMissing) {
+ try {
+ manifest = XPIExports.XPIInstall.syncLoadManifest(
+ aAddonState,
+ aLocation
+ );
+ } catch (err) {
+ // If we can no longer read the manifest, it is no longer compatible.
+ aOldAddon.brokenManifest = true;
+ aOldAddon.appDisabled = true;
+ return aOldAddon;
+ }
+ }
+
+ // If updating from a version of the app that didn't support signedState
+ // then update that property now
+ if (checkSigning) {
+ aOldAddon.signedState = manifest.signedState;
+ }
+
+ if (signedDateMissing) {
+ aOldAddon.signedDate = manifest.signedDate;
+ }
+
+ // May be updating from a version of the app that didn't support all the
+ // properties of the currently-installed add-ons.
+ if (aReloadMetadata) {
+ // Avoid re-reading these properties from manifest,
+ // use existing addon instead.
+ let remove = [
+ "syncGUID",
+ "foreignInstall",
+ "visible",
+ "active",
+ "userDisabled",
+ "embedderDisabled",
+ "applyBackgroundUpdates",
+ "sourceURI",
+ "releaseNotesURI",
+ "installTelemetryInfo",
+ ];
+
+ // TODO - consider re-scanning for targetApplications for other addon types.
+ if (aOldAddon.type !== "locale") {
+ remove.push("targetApplications");
+ }
+
+ let props = PROP_JSON_FIELDS.filter(a => !remove.includes(a));
+ copyProperties(manifest, props, aOldAddon);
+ }
+
+ aOldAddon.appDisabled = !XPIDatabase.isUsableAddon(aOldAddon);
+
+ return aOldAddon;
+ },
+
+ /**
+ * Returns true if this install location is part of the application
+ * bundle. Add-ons in these locations are expected to change whenever
+ * the application updates.
+ *
+ * @param {XPIStateLocation} location
+ * The install location to check.
+ * @returns {boolean}
+ * True if this location is part of the application bundle.
+ */
+ isAppBundledLocation(location) {
+ return (
+ location.name == KEY_APP_GLOBAL ||
+ location.name == KEY_APP_SYSTEM_DEFAULTS ||
+ location.name == KEY_APP_BUILTINS
+ );
+ },
+
+ /**
+ * Returns true if this install location holds system addons.
+ *
+ * @param {XPIStateLocation} location
+ * The install location to check.
+ * @returns {boolean}
+ * True if this location contains system add-ons.
+ */
+ isSystemAddonLocation(location) {
+ return (
+ location.name === KEY_APP_SYSTEM_DEFAULTS ||
+ location.name === KEY_APP_SYSTEM_ADDONS
+ );
+ },
+
+ /**
+ * Updates the databse metadata for an existing add-on during database
+ * reconciliation.
+ *
+ * @param {AddonInternal} oldAddon
+ * The existing database add-on entry.
+ * @param {XPIState} xpiState
+ * The XPIStates entry for this add-on.
+ * @param {AddonInternal?} newAddon
+ * The new add-on metadata for the add-on, as loaded from a
+ * staged update in addonStartup.json.
+ * @param {boolean} aUpdateCompatibility
+ * true to update add-ons appDisabled property when the application
+ * version has changed
+ * @param {boolean} aSchemaChange
+ * The schema has changed and all add-on manifests should be re-read.
+ * @returns {AddonInternal?}
+ * The updated AddonInternal object for the add-on, if one
+ * could be created.
+ */
+ updateExistingAddon(
+ oldAddon,
+ xpiState,
+ newAddon,
+ aUpdateCompatibility,
+ aSchemaChange
+ ) {
+ XPIDatabase.recordAddonTelemetry(oldAddon);
+
+ let installLocation = oldAddon.location;
+
+ // Update the add-on's database metadata from on-disk metadata if:
+ //
+ // a) The add-on was staged for install in the last session,
+ // b) The add-on has been modified since the last session, or,
+ // c) The app has been updated since the last session, and the
+ // add-on is part of the application bundle (and has therefore
+ // likely been replaced in the update process).
+ if (
+ newAddon ||
+ oldAddon.updateDate != xpiState.mtime ||
+ (aUpdateCompatibility && this.isAppBundledLocation(installLocation))
+ ) {
+ newAddon = this.updateMetadata(
+ installLocation,
+ oldAddon,
+ xpiState,
+ newAddon
+ );
+ } else if (oldAddon.path != xpiState.path) {
+ newAddon = this.updatePath(installLocation, oldAddon, xpiState);
+ } else if (aUpdateCompatibility || aSchemaChange) {
+ newAddon = this.updateCompatibility(
+ installLocation,
+ oldAddon,
+ xpiState,
+ aSchemaChange
+ );
+ } else {
+ newAddon = oldAddon;
+ }
+
+ if (newAddon) {
+ newAddon.rootURI = newAddon.rootURI || xpiState.rootURI;
+ }
+
+ return newAddon;
+ },
+
+ /**
+ * Compares the add-ons that are currently installed to those that were
+ * known to be installed when the application last ran and applies any
+ * changes found to the database.
+ * Always called after XPIDatabase.sys.mjs and extensions.json have been
+ * loaded.
+ *
+ * @param {Object} aManifests
+ * A dictionary of cached AddonInstalls for add-ons that have been
+ * installed
+ * @param {boolean} aUpdateCompatibility
+ * true to update add-ons appDisabled property when the application
+ * version has changed
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {boolean} aSchemaChange
+ * The schema has changed and all add-on manifests should be re-read.
+ * @returns {boolean}
+ * A boolean indicating if a change requiring flushing the caches was
+ * detected
+ */
+ processFileChanges(
+ aManifests,
+ aUpdateCompatibility,
+ aOldAppVersion,
+ aOldPlatformVersion,
+ aSchemaChange
+ ) {
+ let findManifest = (loc, id) => {
+ return (aManifests[loc.name] && aManifests[loc.name][id]) || null;
+ };
+
+ let previousAddons = new lazy.ExtensionUtils.DefaultMap(() => new Map());
+ let currentAddons = new lazy.ExtensionUtils.DefaultMap(() => new Map());
+
+ // Get the previous add-ons from the database and put them into maps by location
+ for (let addon of XPIDatabase.getAddons()) {
+ previousAddons.get(addon.location.name).set(addon.id, addon);
+ }
+
+ // Keep track of add-ons whose blocklist status may have changed. We'll check this
+ // after everything else.
+ let addonsToCheckAgainstBlocklist = [];
+
+ // Build the list of current add-ons into similar maps. When add-ons are still
+ // present we re-use the add-on objects from the database and update their
+ // details directly
+ let addonStates = new Map();
+ for (let location of XPIExports.XPIInternal.XPIStates.locations()) {
+ let locationAddons = currentAddons.get(location.name);
+
+ // Get all the on-disk XPI states for this location, and keep track of which
+ // ones we see in the database.
+ let dbAddons = previousAddons.get(location.name) || new Map();
+ for (let [id, oldAddon] of dbAddons) {
+ // Check if the add-on is still installed
+ let xpiState = location.get(id);
+ if (xpiState && !xpiState.missing) {
+ let newAddon = this.updateExistingAddon(
+ oldAddon,
+ xpiState,
+ findManifest(location, id),
+ aUpdateCompatibility,
+ aSchemaChange
+ );
+ if (newAddon) {
+ locationAddons.set(newAddon.id, newAddon);
+
+ // We need to do a blocklist check later, but the add-on may have changed by then.
+ // Avoid storing the current copy and just get one when we need one instead.
+ addonsToCheckAgainstBlocklist.push(newAddon.id);
+ }
+ } else {
+ // The add-on is in the DB, but not in xpiState (and thus not on disk).
+ this.removeMetadata(oldAddon);
+ }
+ }
+
+ for (let [id, xpiState] of location) {
+ if (locationAddons.has(id) || xpiState.missing) {
+ continue;
+ }
+ let newAddon = findManifest(location, id);
+ let addon = this.addMetadata(
+ location,
+ id,
+ xpiState,
+ newAddon,
+ aOldAppVersion,
+ aOldPlatformVersion
+ );
+ if (addon) {
+ locationAddons.set(addon.id, addon);
+ addonStates.set(addon, xpiState);
+ }
+ }
+
+ if (this.isSystemAddonLocation(location)) {
+ for (let [id, addon] of locationAddons.entries()) {
+ const pref = `extensions.${id.split("@")[0]}.enabled`;
+ addon.userDisabled = !Services.prefs.getBoolPref(pref, true);
+ }
+ }
+ }
+
+ // Validate the updated system add-ons
+ let hideLocation;
+ {
+ let systemAddonLocation = XPIExports.XPIInternal.XPIStates.getLocation(
+ KEY_APP_SYSTEM_ADDONS
+ );
+ let addons = currentAddons.get(systemAddonLocation.name);
+
+ if (!systemAddonLocation.installer.isValid(addons)) {
+ // Hide the system add-on updates if any are invalid.
+ logger.info(
+ "One or more updated system add-ons invalid, falling back to defaults."
+ );
+ hideLocation = systemAddonLocation.name;
+ }
+ }
+
+ // Apply startup changes to any currently-visible add-ons, and
+ // uninstall any which were previously visible, but aren't anymore.
+ let previousVisible = this.getVisibleAddons(previousAddons);
+ let currentVisible = this.flattenByID(currentAddons, hideLocation);
+
+ for (let addon of XPIDatabase.orphanedAddons.splice(0)) {
+ if (addon.visible) {
+ previousVisible.set(addon.id, addon);
+ }
+ }
+
+ let promises = [];
+ for (let [id, addon] of currentVisible) {
+ // If we have a stored manifest for the add-on, it came from the
+ // startup data cache, and supersedes any previous XPIStates entry.
+ let xpiState =
+ !findManifest(addon.location, id) && addonStates.get(addon);
+
+ promises.push(
+ this.applyStartupChange(addon, previousVisible.get(id), xpiState)
+ );
+ previousVisible.delete(id);
+ }
+
+ if (promises.some(p => p)) {
+ XPIExports.XPIInternal.awaitPromise(Promise.all(promises));
+ }
+
+ for (let [id, addon] of previousVisible) {
+ if (addon.location) {
+ if (addon.location.name == KEY_APP_BUILTINS) {
+ continue;
+ }
+ XPIExports.XPIInternal.BootstrapScope.get(addon).uninstall();
+ addon.location.removeAddon(id);
+ addon.visible = false;
+ addon.active = false;
+ }
+
+ lazy.AddonManagerPrivate.addStartupChange(
+ lazy.AddonManager.STARTUP_CHANGE_UNINSTALLED,
+ id
+ );
+ }
+
+ // Finally update XPIStates to match everything
+ for (let [locationName, locationAddons] of currentAddons) {
+ for (let [id, addon] of locationAddons) {
+ let xpiState = XPIExports.XPIInternal.XPIStates.getAddon(
+ locationName,
+ id
+ );
+ xpiState.syncWithDB(addon);
+ }
+ }
+ XPIExports.XPIInternal.XPIStates.save();
+ XPIDatabase.saveChanges();
+ XPIDatabase.rebuildingDatabase = false;
+
+ if (aUpdateCompatibility || aSchemaChange) {
+ // Do some blocklist checks. These will happen after we've just saved everything,
+ // because they're async and depend on the blocklist loading. When we're done, save
+ // the data if any of the add-ons' blocklist state has changed.
+ lazy.AddonManager.beforeShutdown.addBlocker(
+ "Update add-on blocklist state into add-on DB",
+ (async () => {
+ // Avoid querying the AddonManager immediately to give startup a chance
+ // to complete.
+ await Promise.resolve();
+
+ let addons = await lazy.AddonManager.getAddonsByIDs(
+ addonsToCheckAgainstBlocklist
+ );
+ await Promise.all(
+ addons.map(async addon => {
+ if (!addon) {
+ return;
+ }
+ let oldState = addon.blocklistState;
+ // TODO 1712316: updateBlocklistState with object parameter only
+ // works if addon is an AddonInternal instance. But addon is an
+ // AddonWrapper instead. Consequently updateDate:false is ignored.
+ await addon.updateBlocklistState({ updateDatabase: false });
+ if (oldState !== addon.blocklistState) {
+ lazy.Blocklist.recordAddonBlockChangeTelemetry(
+ addon,
+ "addon_db_modified"
+ );
+ }
+ })
+ );
+
+ XPIDatabase.saveChanges();
+ })()
+ );
+ }
+
+ return true;
+ },
+
+ /**
+ * Applies a startup change for the given add-on.
+ *
+ * @param {AddonInternal} currentAddon
+ * The add-on as it exists in this session.
+ * @param {AddonInternal?} previousAddon
+ * The add-on as it existed in the previous session.
+ * @param {XPIState?} xpiState
+ * The XPIState entry for this add-on, if one exists.
+ * @returns {Promise?}
+ * If an update was performed, returns a promise which resolves
+ * when the appropriate bootstrap methods have been called.
+ */
+ applyStartupChange(currentAddon, previousAddon, xpiState) {
+ let promise;
+ let { id } = currentAddon;
+
+ let isActive = !currentAddon.disabled;
+ let wasActive = previousAddon ? previousAddon.active : currentAddon.active;
+
+ if (previousAddon) {
+ if (previousAddon !== currentAddon) {
+ lazy.AddonManagerPrivate.addStartupChange(
+ lazy.AddonManager.STARTUP_CHANGE_CHANGED,
+ id
+ );
+
+ // Bug 1664144: If the addon changed on disk we will catch it during
+ // the second scan initiated by getNewSideloads. The addon may have
+ // already started, if so we need to ensure it restarts during the
+ // update, otherwise we're left in a state where the addon is enabled
+ // but not started. We use the bootstrap started state to check that.
+ // isActive alone is not sufficient as that changes the characteristics
+ // of other updates and breaks many tests.
+ let restart =
+ isActive &&
+ XPIExports.XPIInternal.BootstrapScope.get(currentAddon).started;
+ if (restart) {
+ logger.warn(
+ `Updating and restart addon ${previousAddon.id} that changed on disk after being already started.`
+ );
+ }
+ promise = XPIExports.XPIInternal.BootstrapScope.get(
+ previousAddon
+ ).update(currentAddon, restart);
+ }
+
+ if (isActive != wasActive) {
+ let change = isActive
+ ? lazy.AddonManager.STARTUP_CHANGE_ENABLED
+ : lazy.AddonManager.STARTUP_CHANGE_DISABLED;
+ lazy.AddonManagerPrivate.addStartupChange(change, id);
+ }
+ } else if (xpiState && xpiState.wasRestored) {
+ isActive = xpiState.enabled;
+
+ if (currentAddon.isWebExtension && currentAddon.type == "theme") {
+ currentAddon.userDisabled = !isActive;
+ }
+
+ // If the add-on wasn't active and it isn't already disabled in some way
+ // then it was probably either softDisabled or userDisabled
+ if (!isActive && !currentAddon.disabled) {
+ // If the add-on is softblocked then assume it is softDisabled
+ if (
+ currentAddon.blocklistState == Services.blocklist.STATE_SOFTBLOCKED
+ ) {
+ currentAddon.softDisabled = true;
+ } else {
+ currentAddon.userDisabled = true;
+ }
+ }
+ } else {
+ lazy.AddonManagerPrivate.addStartupChange(
+ lazy.AddonManager.STARTUP_CHANGE_INSTALLED,
+ id
+ );
+ let scope = XPIExports.XPIInternal.BootstrapScope.get(currentAddon);
+ scope.install();
+ }
+
+ XPIDatabase.makeAddonVisible(currentAddon);
+ currentAddon.active = isActive;
+ return promise;
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/XPIExports.sys.mjs b/toolkit/mozapps/extensions/internal/XPIExports.sys.mjs
new file mode 100644
index 0000000000..3fdd03e660
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/XPIExports.sys.mjs
@@ -0,0 +1,36 @@
+/* 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/. */
+
+/**
+ * This file wraps XPIDatabase, XPIInstall, and XPIProvider modules in order to
+ * allow testing the shutdown+restart situation in AddonTestUtils.sys.mjs.
+ */
+
+// A shared `lazy` object for exports from XPIDatabase, XPIInternal, and
+// XPIProvider modules.
+//
+// Consumers shouldn't store those property values to global variables, except
+// for registering XPIProvider.
+//
+// The list of lazy getters should be in sync with resetXPIExports in
+// AddonTestUtils.sys.mjs.
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ // XPIDatabase.sys.mjs
+ AddonInternal: "resource://gre/modules/addons/XPIDatabase.sys.mjs",
+ BuiltInThemesHelpers: "resource://gre/modules/addons/XPIDatabase.sys.mjs",
+ XPIDatabase: "resource://gre/modules/addons/XPIDatabase.sys.mjs",
+ XPIDatabaseReconcile: "resource://gre/modules/addons/XPIDatabase.sys.mjs",
+
+ // XPIInstall.sys.mjs
+ UpdateChecker: "resource://gre/modules/addons/XPIInstall.sys.mjs",
+ XPIInstall: "resource://gre/modules/addons/XPIInstall.sys.mjs",
+ verifyBundleSignedState: "resource://gre/modules/addons/XPIInstall.sys.mjs",
+
+ // XPIProvider.sys.mjs
+ XPIProvider: "resource://gre/modules/addons/XPIProvider.sys.mjs",
+ XPIInternal: "resource://gre/modules/addons/XPIProvider.sys.mjs",
+});
+
+export { lazy as XPIExports };
diff --git a/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs b/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs
new file mode 100644
index 0000000000..1a80407ad2
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs
@@ -0,0 +1,4897 @@
+/* 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/. */
+
+/**
+ * This file contains most of the logic required to install extensions.
+ * In general, we try to avoid loading it until extension installation
+ * or update is required. Please keep that in mind when deciding whether
+ * to add code here or elsewhere.
+ */
+
+/**
+ * @typedef {number} integer
+ */
+
+/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { XPIExports } from "resource://gre/modules/addons/XPIExports.sys.mjs";
+import {
+ computeSha256HashAsString,
+ getHashStringForCrypto,
+} from "resource://gre/modules/addons/crypto-utils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import {
+ AddonManager,
+ AddonManagerPrivate,
+} from "resource://gre/modules/AddonManager.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ CertUtils: "resource://gre/modules/CertUtils.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ ProductAddonChecker:
+ "resource://gre/modules/addons/ProductAddonChecker.sys.mjs",
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "IconDetails", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+ ).ExtensionParent.IconDetails;
+});
+
+const { nsIBlocklistService } = Ci;
+
+const nsIFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+
+const BinaryOutputStream = Components.Constructor(
+ "@mozilla.org/binaryoutputstream;1",
+ "nsIBinaryOutputStream",
+ "setOutputStream"
+);
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+const FileInputStream = Components.Constructor(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+const FileOutputStream = Components.Constructor(
+ "@mozilla.org/network/file-output-stream;1",
+ "nsIFileOutputStream",
+ "init"
+);
+const ZipReader = Components.Constructor(
+ "@mozilla.org/libjar/zip-reader;1",
+ "nsIZipReader",
+ "open"
+);
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ gCertDB: ["@mozilla.org/security/x509certdb;1", "nsIX509CertDB"],
+});
+
+const PREF_INSTALL_REQUIRESECUREORIGIN =
+ "extensions.install.requireSecureOrigin";
+const PREF_PENDING_OPERATIONS = "extensions.pendingOperations";
+const PREF_SYSTEM_ADDON_UPDATE_URL = "extensions.systemAddon.update.url";
+const PREF_XPI_ENABLED = "xpinstall.enabled";
+const PREF_XPI_DIRECT_WHITELISTED = "xpinstall.whitelist.directRequest";
+const PREF_XPI_FILE_WHITELISTED = "xpinstall.whitelist.fileRequest";
+const PREF_XPI_WHITELIST_REQUIRED = "xpinstall.whitelist.required";
+
+const PREF_SELECTED_THEME = "extensions.activeThemeID";
+
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+/**
+ * Returns a nsIFile instance for the given path, relative to the given
+ * base file, if provided.
+ *
+ * @param {string} path
+ * The (possibly relative) path of the file.
+ * @param {nsIFile} [base]
+ * An optional file to use as a base path if `path` is relative.
+ * @returns {nsIFile}
+ */
+function getFile(path, base = null) {
+ // First try for an absolute path, as we get in the case of proxy
+ // files. Ideally we would try a relative path first, but on Windows,
+ // paths which begin with a drive letter are valid as relative paths,
+ // and treated as such.
+ try {
+ return new nsIFile(path);
+ } catch (e) {
+ // Ignore invalid relative paths. The only other error we should see
+ // here is EOM, and either way, any errors that we care about should
+ // be re-thrown below.
+ }
+
+ // If the path isn't absolute, we must have a base path.
+ let file = base.clone();
+ file.appendRelativePath(path);
+ return file;
+}
+
+/**
+ * Sends local and remote notifications to flush a JAR file cache entry
+ *
+ * @param {nsIFile} aJarFile
+ * The ZIP/XPI/JAR file as a nsIFile
+ */
+function flushJarCache(aJarFile) {
+ Services.obs.notifyObservers(aJarFile, "flush-cache-entry");
+ Services.ppmm.broadcastAsyncMessage(MSG_JAR_FLUSH, {
+ path: aJarFile.path,
+ });
+}
+
+const PREF_EM_UPDATE_BACKGROUND_URL = "extensions.update.background.url";
+const PREF_EM_UPDATE_URL = "extensions.update.url";
+const PREF_XPI_SIGNATURES_DEV_ROOT = "xpinstall.signatures.dev-root";
+
+const KEY_TEMPDIR = "TmpD";
+
+// This is a random number array that can be used as "salt" when generating
+// an automatic ID based on the directory path of an add-on. It will prevent
+// someone from creating an ID for a permanent add-on that could be replaced
+// by a temporary add-on (because that would be confusing, I guess).
+const TEMP_INSTALL_ID_GEN_SESSION = new Uint8Array(
+ Float64Array.of(Math.random()).buffer
+);
+
+const MSG_JAR_FLUSH = "Extension:FlushJarCache";
+
+/**
+ * Valid IDs fit this pattern.
+ */
+var gIDTest =
+ /^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i;
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.xpi";
+
+// Create a new logger for use by all objects in this Addons XPI Provider module
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+// Stores the ID of the theme which was selected during the last session,
+// if any. When installing a new built-in theme with this ID, it will be
+// automatically enabled.
+let lastSelectedTheme = null;
+
+function getJarURI(file, path = "") {
+ if (file instanceof Ci.nsIFile) {
+ file = Services.io.newFileURI(file);
+ }
+ if (file instanceof Ci.nsIURI) {
+ file = file.spec;
+ }
+ return Services.io.newURI(`jar:${file}!/${path}`);
+}
+
+let DirPackage;
+let XPIPackage;
+class Package {
+ static get(file) {
+ if (file.isFile()) {
+ return new XPIPackage(file);
+ }
+ return new DirPackage(file);
+ }
+
+ constructor(file, rootURI) {
+ this.file = file;
+ this.filePath = file.path;
+ this.rootURI = rootURI;
+ }
+
+ close() {}
+
+ async readString(...path) {
+ let buffer = await this.readBinary(...path);
+ return new TextDecoder().decode(buffer);
+ }
+
+ async verifySignedState(addonId, addonType, addonLocation) {
+ if (!shouldVerifySignedState(addonType, addonLocation)) {
+ return {
+ signedState: AddonManager.SIGNEDSTATE_NOT_REQUIRED,
+ cert: null,
+ };
+ }
+
+ let root = Ci.nsIX509CertDB.AddonsPublicRoot;
+ if (
+ !AppConstants.MOZ_REQUIRE_SIGNING &&
+ Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)
+ ) {
+ root = Ci.nsIX509CertDB.AddonsStageRoot;
+ }
+
+ return this.verifySignedStateForRoot(addonId, root);
+ }
+
+ flushCache() {}
+}
+
+DirPackage = class DirPackage extends Package {
+ constructor(file) {
+ super(file, Services.io.newFileURI(file));
+ }
+
+ hasResource(...path) {
+ return IOUtils.exists(PathUtils.join(this.filePath, ...path));
+ }
+
+ async iterDirectory(path, callback) {
+ let fullPath = PathUtils.join(this.filePath, ...path);
+
+ let children = await IOUtils.getChildren(fullPath);
+ for (let path of children) {
+ let { type } = await IOUtils.stat(path);
+ callback({
+ isDir: type == "directory",
+ name: PathUtils.filename(path),
+ path,
+ });
+ }
+ }
+
+ iterFiles(callback, path = []) {
+ return this.iterDirectory(path, async entry => {
+ let entryPath = [...path, entry.name];
+ if (entry.isDir) {
+ callback({
+ path: entryPath.join("/"),
+ isDir: true,
+ });
+ await this.iterFiles(callback, entryPath);
+ } else {
+ callback({
+ path: entryPath.join("/"),
+ isDir: false,
+ });
+ }
+ });
+ }
+
+ readBinary(...path) {
+ return IOUtils.read(PathUtils.join(this.filePath, ...path));
+ }
+
+ async verifySignedStateForRoot(addonId, root) {
+ return { signedState: AddonManager.SIGNEDSTATE_UNKNOWN, cert: null };
+ }
+};
+
+XPIPackage = class XPIPackage extends Package {
+ constructor(file) {
+ super(file, getJarURI(file));
+
+ this.zipReader = new ZipReader(file);
+ }
+
+ close() {
+ this.zipReader.close();
+ this.zipReader = null;
+ this.flushCache();
+ }
+
+ async hasResource(...path) {
+ return this.zipReader.hasEntry(path.join("/"));
+ }
+
+ async iterFiles(callback) {
+ for (let path of this.zipReader.findEntries("*")) {
+ let entry = this.zipReader.getEntry(path);
+ callback({
+ path,
+ isDir: entry.isDirectory,
+ });
+ }
+ }
+
+ async readBinary(...path) {
+ let response = await fetch(this.rootURI.resolve(path.join("/")));
+ return response.arrayBuffer();
+ }
+
+ verifySignedStateForRoot(addonId, root) {
+ return new Promise(resolve => {
+ let callback = {
+ openSignedAppFileFinished(aRv, aZipReader, aCert) {
+ if (aZipReader) {
+ aZipReader.close();
+ }
+ resolve({
+ signedState: getSignedStatus(aRv, aCert, addonId),
+ cert: aCert,
+ });
+ },
+ };
+ // This allows the certificate DB to get the raw JS callback object so the
+ // test code can pass through objects that XPConnect would reject.
+ callback.wrappedJSObject = callback;
+
+ lazy.gCertDB.openSignedAppFileAsync(root, this.file, callback);
+ });
+ }
+
+ flushCache() {
+ flushJarCache(this.file);
+ }
+};
+
+/**
+ * Return an object that implements enough of the Package interface
+ * to allow loadManifest() to work for a built-in addon (ie, one loaded
+ * from a resource: url)
+ *
+ * @param {nsIURL} baseURL The URL for the root of the add-on.
+ * @returns {object}
+ */
+function builtinPackage(baseURL) {
+ return {
+ rootURI: baseURL,
+ filePath: baseURL.spec,
+ file: null,
+ verifySignedState() {
+ return {
+ signedState: AddonManager.SIGNEDSTATE_NOT_REQUIRED,
+ cert: null,
+ };
+ },
+ async hasResource(path) {
+ try {
+ let response = await fetch(this.rootURI.resolve(path));
+ return response.ok;
+ } catch (e) {
+ return false;
+ }
+ },
+ };
+}
+
+/**
+ * Determine the reason to pass to an extension's bootstrap methods when
+ * switch between versions.
+ *
+ * @param {string} oldVersion The version of the existing extension instance.
+ * @param {string} newVersion The version of the extension being installed.
+ *
+ * @returns {integer}
+ * BOOSTRAP_REASONS.ADDON_UPGRADE or BOOSTRAP_REASONS.ADDON_DOWNGRADE
+ */
+function newVersionReason(oldVersion, newVersion) {
+ return Services.vc.compare(oldVersion, newVersion) <= 0
+ ? XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_UPGRADE
+ : XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_DOWNGRADE;
+}
+
+// Behaves like Promise.all except waits for all promises to resolve/reject
+// before resolving/rejecting itself
+function waitForAllPromises(promises) {
+ return new Promise((resolve, reject) => {
+ let shouldReject = false;
+ let rejectValue = null;
+
+ let newPromises = promises.map(p =>
+ p.catch(value => {
+ shouldReject = true;
+ rejectValue = value;
+ })
+ );
+ Promise.all(newPromises).then(results =>
+ shouldReject ? reject(rejectValue) : resolve(results)
+ );
+ });
+}
+
+/**
+ * Reads an AddonInternal object from a webextension manifest.json
+ *
+ * @param {Package} aPackage
+ * The install package for the add-on
+ * @param {XPIStateLocation} aLocation
+ * The install location the add-on is installed in, or will be
+ * installed to.
+ * @returns {{ addon: AddonInternal, verifiedSignedState: object}}
+ * @throws if the install manifest in the stream is corrupt or could not
+ * be read
+ */
+async function loadManifestFromWebManifest(aPackage, aLocation) {
+ let verifiedSignedState;
+ const temporarilyInstalled = aLocation.isTemporary;
+ let extension = await lazy.ExtensionData.constructAsync({
+ rootURI: XPIExports.XPIInternal.maybeResolveURI(aPackage.rootURI),
+ temporarilyInstalled,
+ async checkPrivileged(type, id) {
+ verifiedSignedState = await aPackage.verifySignedState(
+ id,
+ type,
+ aLocation
+ );
+ return lazy.ExtensionData.getIsPrivileged({
+ signedState: verifiedSignedState.signedState,
+ builtIn: aLocation.isBuiltin,
+ temporarilyInstalled,
+ });
+ },
+ });
+
+ let manifest = await extension.loadManifest();
+
+ // Read the list of available locales, and pre-load messages for
+ // all locales.
+ let locales = !extension.errors.length
+ ? await extension.initAllLocales()
+ : null;
+
+ if (extension.errors.length) {
+ let error = new Error("Extension is invalid");
+ // Add detailed errors on the error object so that the front end can display them
+ // if needed (eg in about:debugging).
+ error.additionalErrors = extension.errors;
+ throw error;
+ }
+
+ // Internally, we use the `applications` key but it is because we assign the value
+ // of `browser_specific_settings` to `applications` in `ExtensionData.parseManifest()`.
+ // Yet, as of MV3, only `browser_specific_settings` is accepted in manifest.json files.
+ let bss = manifest.applications?.gecko || {};
+
+ // A * is illegal in strict_min_version
+ if (bss.strict_min_version?.split(".").some(part => part == "*")) {
+ throw new Error("The use of '*' in strict_min_version is invalid");
+ }
+
+ let addon = new XPIExports.AddonInternal();
+ addon.id = bss.id;
+ addon.version = manifest.version;
+ addon.manifestVersion = manifest.manifest_version;
+ addon.type = extension.type;
+ addon.loader = null;
+ addon.strictCompatibility = true;
+ addon.internalName = null;
+ addon.updateURL = bss.update_url;
+ addon.installOrigins = manifest.install_origins;
+ addon.optionsBrowserStyle = true;
+ addon.optionsURL = null;
+ addon.optionsType = null;
+ addon.aboutURL = null;
+ addon.dependencies = Object.freeze(Array.from(extension.dependencies));
+ addon.startupData = extension.startupData;
+ addon.hidden = extension.isPrivileged && manifest.hidden;
+ addon.incognito = manifest.incognito;
+
+ if (addon.type === "theme" && (await aPackage.hasResource("preview.png"))) {
+ addon.previewImage = "preview.png";
+ }
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ if (addon.type == "sitepermission-deprecated") {
+ addon.sitePermissions = manifest.site_permissions;
+ addon.siteOrigin = manifest.install_origins[0];
+ }
+
+ if (manifest.options_ui) {
+ // Store just the relative path here, the AddonWrapper getURL
+ // wrapper maps this to a full URL.
+ addon.optionsURL = manifest.options_ui.page;
+ if (manifest.options_ui.open_in_tab) {
+ addon.optionsType = AddonManager.OPTIONS_TYPE_TAB;
+ } else {
+ addon.optionsType = AddonManager.OPTIONS_TYPE_INLINE_BROWSER;
+ }
+
+ addon.optionsBrowserStyle = manifest.options_ui.browser_style;
+ }
+
+ // WebExtensions don't use iconURLs
+ addon.iconURL = null;
+ addon.icons = manifest.icons || {};
+ addon.userPermissions = extension.manifestPermissions;
+ addon.optionalPermissions = extension.manifestOptionalPermissions;
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
+
+ function getLocale(aLocale) {
+ // Use the raw manifest, here, since we need values with their
+ // localization placeholders still in place.
+ let rawManifest = extension.rawManifest;
+
+ // As a convenience, allow author to be set if its a string bug 1313567.
+ let creator =
+ typeof rawManifest.author === "string" ? rawManifest.author : null;
+ let homepageURL = rawManifest.homepage_url;
+
+ // Allow developer to override creator and homepage_url.
+ if (rawManifest.developer) {
+ if (rawManifest.developer.name) {
+ creator = rawManifest.developer.name;
+ }
+ if (rawManifest.developer.url) {
+ homepageURL = rawManifest.developer.url;
+ }
+ }
+
+ let result = {
+ name: extension.localize(rawManifest.name, aLocale),
+ description: extension.localize(rawManifest.description, aLocale),
+ creator: extension.localize(creator, aLocale),
+ homepageURL: extension.localize(homepageURL, aLocale),
+
+ developers: null,
+ translators: null,
+ contributors: null,
+ locales: [aLocale],
+ };
+ return result;
+ }
+
+ addon.defaultLocale = getLocale(extension.defaultLocale);
+ addon.locales = Array.from(locales.keys(), getLocale);
+
+ delete addon.defaultLocale.locales;
+
+ addon.targetApplications = [
+ {
+ id: TOOLKIT_ID,
+ minVersion: bss.strict_min_version,
+ maxVersion: bss.strict_max_version,
+ },
+ ];
+
+ addon.targetPlatforms = [];
+ // Themes are disabled by default, except when they're installed from a web page.
+ addon.userDisabled = extension.type === "theme";
+ addon.softDisabled =
+ addon.blocklistState == nsIBlocklistService.STATE_SOFTBLOCKED;
+
+ return { addon, verifiedSignedState };
+}
+
+async function readRecommendationStates(aPackage, aAddonID) {
+ let recommendationData;
+ try {
+ recommendationData = await aPackage.readString(
+ "mozilla-recommendation.json"
+ );
+ } catch (e) {
+ // Ignore I/O errors.
+ return null;
+ }
+
+ try {
+ recommendationData = JSON.parse(recommendationData);
+ } catch (e) {
+ logger.warn("Failed to parse recommendation", e);
+ }
+
+ if (recommendationData) {
+ let { addon_id, states, validity } = recommendationData;
+
+ if (addon_id === aAddonID && Array.isArray(states) && validity) {
+ let validNotAfter = Date.parse(validity.not_after);
+ let validNotBefore = Date.parse(validity.not_before);
+ if (validNotAfter && validNotBefore) {
+ return {
+ validNotAfter,
+ validNotBefore,
+ states,
+ };
+ }
+ }
+ logger.warn(
+ `Invalid recommendation for ${aAddonID}: ${JSON.stringify(
+ recommendationData
+ )}`
+ );
+ }
+
+ return null;
+}
+
+function defineSyncGUID(aAddon) {
+ // Define .syncGUID as a lazy property which is also settable
+ Object.defineProperty(aAddon, "syncGUID", {
+ get: () => {
+ aAddon.syncGUID = Services.uuid.generateUUID().toString();
+ return aAddon.syncGUID;
+ },
+ set: val => {
+ delete aAddon.syncGUID;
+ aAddon.syncGUID = val;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+}
+
+// Generate a unique ID based on the path to this temporary add-on location.
+function generateTemporaryInstallID(aFile) {
+ const hasher = CryptoHash("sha1");
+ const data = new TextEncoder().encode(aFile.path);
+ // Make it so this ID cannot be guessed.
+ const sess = TEMP_INSTALL_ID_GEN_SESSION;
+ hasher.update(sess, sess.length);
+ hasher.update(data, data.length);
+ let id = `${getHashStringForCrypto(hasher)}${
+ XPIExports.XPIInternal.TEMPORARY_ADDON_SUFFIX
+ }`;
+ logger.info(`Generated temp id ${id} (${sess.join("")}) for ${aFile.path}`);
+ return id;
+}
+
+var loadManifest = async function (aPackage, aLocation, aOldAddon) {
+ let addon;
+ let verifiedSignedState;
+ if (await aPackage.hasResource("manifest.json")) {
+ ({ addon, verifiedSignedState } = await loadManifestFromWebManifest(
+ aPackage,
+ aLocation
+ ));
+ } else {
+ // TODO bug 1674799: Remove this unused branch.
+ for (let loader of AddonManagerPrivate.externalExtensionLoaders.values()) {
+ if (await aPackage.hasResource(loader.manifestFile)) {
+ addon = await loader.loadManifest(aPackage);
+ addon.loader = loader.name;
+ verifiedSignedState = await aPackage.verifySignedState(
+ addon.id,
+ addon.type,
+ aLocation
+ );
+ break;
+ }
+ }
+ }
+
+ if (!addon) {
+ throw new Error(
+ `File ${aPackage.filePath} does not contain a valid manifest`
+ );
+ }
+
+ addon._sourceBundle = aPackage.file;
+ addon.rootURI = aPackage.rootURI.spec;
+ addon.location = aLocation;
+
+ let { signedState, cert } = verifiedSignedState;
+ addon.signedState = signedState;
+ addon.signedDate = cert?.validity?.notBefore / 1000 || null;
+
+ if (!addon.id) {
+ if (cert) {
+ addon.id = cert.commonName;
+ if (!gIDTest.test(addon.id)) {
+ throw new Error(`Extension is signed with an invalid id (${addon.id})`);
+ }
+ }
+ if (!addon.id && aLocation.isTemporary) {
+ addon.id = generateTemporaryInstallID(aPackage.file);
+ }
+ }
+
+ addon.propagateDisabledState(aOldAddon);
+ if (!aLocation.isSystem && !aLocation.isBuiltin) {
+ if (addon.type === "extension" && !aLocation.isTemporary) {
+ addon.recommendationState = await readRecommendationStates(
+ aPackage,
+ addon.id
+ );
+ }
+
+ await addon.updateBlocklistState();
+ addon.appDisabled = !XPIExports.XPIDatabase.isUsableAddon(addon);
+
+ // Always report when there is an attempt to install a blocked add-on.
+ // (transitions from STATE_BLOCKED to STATE_NOT_BLOCKED are checked
+ // in the individual AddonInstall subclasses).
+ if (addon.blocklistState == nsIBlocklistService.STATE_BLOCKED) {
+ addon.recordAddonBlockChangeTelemetry(
+ aOldAddon ? "addon_update" : "addon_install"
+ );
+ }
+ }
+
+ defineSyncGUID(addon);
+
+ return addon;
+};
+
+/**
+ * Loads an add-on's manifest from the given file or directory.
+ *
+ * @param {nsIFile} aFile
+ * The file to load the manifest from.
+ * @param {XPIStateLocation} aLocation
+ * The install location the add-on is installed in, or will be
+ * installed to.
+ * @param {AddonInternal?} aOldAddon
+ * The currently-installed add-on with the same ID, if one exist.
+ * This is used to migrate user settings like the add-on's
+ * disabled state.
+ * @returns {AddonInternal}
+ * The parsed Addon object for the file's manifest.
+ */
+var loadManifestFromFile = async function (aFile, aLocation, aOldAddon) {
+ let pkg = Package.get(aFile);
+ try {
+ let addon = await loadManifest(pkg, aLocation, aOldAddon);
+ return addon;
+ } finally {
+ pkg.close();
+ }
+};
+
+/*
+ * A synchronous method for loading an add-on's manifest. Do not use
+ * this.
+ */
+function syncLoadManifest(state, location, oldAddon) {
+ if (location.name == "app-builtin") {
+ let pkg = builtinPackage(Services.io.newURI(state.rootURI));
+ return XPIExports.XPIInternal.awaitPromise(
+ loadManifest(pkg, location, oldAddon)
+ );
+ }
+
+ let file = new nsIFile(state.path);
+ let pkg = Package.get(file);
+ return XPIExports.XPIInternal.awaitPromise(
+ (async () => {
+ try {
+ let addon = await loadManifest(pkg, location, oldAddon);
+ addon.rootURI = XPIExports.XPIInternal.getURIForResourceInFile(
+ file,
+ ""
+ ).spec;
+ return addon;
+ } finally {
+ pkg.close();
+ }
+ })()
+ );
+}
+
+/**
+ * Creates and returns a new unique temporary file. The caller should delete
+ * the file when it is no longer needed.
+ *
+ * @returns {nsIFile}
+ * An nsIFile that points to a randomly named, initially empty file in
+ * the OS temporary files directory
+ */
+function getTemporaryFile() {
+ let file = lazy.FileUtils.getDir(KEY_TEMPDIR, []);
+ let random = Math.round(Math.random() * 36 ** 3).toString(36);
+ file.append(`tmp-${random}.xpi`);
+ file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, lazy.FileUtils.PERMS_FILE);
+ return file;
+}
+
+function getHashForFile(file, algorithm) {
+ let crypto = CryptoHash(algorithm);
+ let fis = new FileInputStream(file, -1, -1, false);
+ try {
+ crypto.updateFromStream(fis, file.fileSize);
+ } finally {
+ fis.close();
+ }
+ return getHashStringForCrypto(crypto);
+}
+
+/**
+ * Returns the signedState for a given return code and certificate by verifying
+ * it against the expected ID.
+ *
+ * @param {nsresult} aRv
+ * The result code returned by the signature checker for the
+ * signature check operation.
+ * @param {nsIX509Cert?} aCert
+ * The certificate the add-on was signed with, if a valid
+ * certificate exists.
+ * @param {string?} aAddonID
+ * The expected ID of the add-on. If passed, this must match the
+ * ID in the certificate's CN field.
+ * @returns {number}
+ * A SIGNEDSTATE result code constant, as defined on the
+ * AddonManager class.
+ */
+function getSignedStatus(aRv, aCert, aAddonID) {
+ let expectedCommonName = aAddonID;
+ if (aAddonID && aAddonID.length > 64) {
+ expectedCommonName = computeSha256HashAsString(aAddonID);
+ }
+
+ switch (aRv) {
+ case Cr.NS_OK:
+ if (expectedCommonName && expectedCommonName != aCert.commonName) {
+ return AddonManager.SIGNEDSTATE_BROKEN;
+ }
+
+ if (aCert.organizationalUnit == "Mozilla Components") {
+ return AddonManager.SIGNEDSTATE_SYSTEM;
+ }
+
+ if (aCert.organizationalUnit == "Mozilla Extensions") {
+ return AddonManager.SIGNEDSTATE_PRIVILEGED;
+ }
+
+ return /preliminary/i.test(aCert.organizationalUnit)
+ ? AddonManager.SIGNEDSTATE_PRELIMINARY
+ : AddonManager.SIGNEDSTATE_SIGNED;
+ case Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED:
+ return AddonManager.SIGNEDSTATE_MISSING;
+ case Cr.NS_ERROR_SIGNED_JAR_MANIFEST_INVALID:
+ case Cr.NS_ERROR_SIGNED_JAR_ENTRY_INVALID:
+ case Cr.NS_ERROR_SIGNED_JAR_ENTRY_MISSING:
+ case Cr.NS_ERROR_SIGNED_JAR_ENTRY_TOO_LARGE:
+ case Cr.NS_ERROR_SIGNED_JAR_UNSIGNED_ENTRY:
+ case Cr.NS_ERROR_SIGNED_JAR_MODIFIED_ENTRY:
+ return AddonManager.SIGNEDSTATE_BROKEN;
+ default:
+ // Any other error indicates that either the add-on isn't signed or it
+ // is signed by a signature that doesn't chain to the trusted root.
+ return AddonManager.SIGNEDSTATE_UNKNOWN;
+ }
+}
+
+function shouldVerifySignedState(aAddonType, aLocation) {
+ // TODO when KEY_APP_SYSTEM_DEFAULTS and KEY_APP_SYSTEM_ADDONS locations
+ // are removed, we need to reorganize the logic here. At that point we
+ // should:
+ // if builtin or MOZ_UNSIGNED_SCOPES return false
+ // if system return true
+ // return SIGNED_TYPES.has(type)
+
+ // We don't care about signatures for default system add-ons
+ if (aLocation.name == XPIExports.XPIInternal.KEY_APP_SYSTEM_DEFAULTS) {
+ return false;
+ }
+
+ // Updated system add-ons should always have their signature checked
+ if (aLocation.isSystem) {
+ return true;
+ }
+
+ if (
+ aLocation.isBuiltin ||
+ aLocation.scope & AppConstants.MOZ_UNSIGNED_SCOPES
+ ) {
+ return false;
+ }
+
+ // Otherwise only check signatures if the add-on is one of the signed
+ // types.
+ return XPIExports.XPIDatabase.SIGNED_TYPES.has(aAddonType);
+}
+
+/**
+ * Verifies that a bundle's contents are all correctly signed by an
+ * AMO-issued certificate
+ *
+ * @param {nsIFile} aBundle
+ * The nsIFile for the bundle to check, either a directory or zip file.
+ * @param {AddonInternal} aAddon
+ * The add-on object to verify.
+ * @returns {Promise<number>}
+ * A Promise that resolves to an AddonManager.SIGNEDSTATE_* constant.
+ */
+export var verifyBundleSignedState = async function (aBundle, aAddon) {
+ let pkg = Package.get(aBundle);
+ try {
+ let { signedState } = await pkg.verifySignedState(
+ aAddon.id,
+ aAddon.type,
+ aAddon.location
+ );
+ return signedState;
+ } finally {
+ pkg.close();
+ }
+};
+
+/**
+ * Replaces %...% strings in an addon url (update and updateInfo) with
+ * appropriate values.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal representing the add-on
+ * @param {string} aUri
+ * The URI to escape
+ * @param {integer?} aUpdateType
+ * An optional number representing the type of update, only applicable
+ * when creating a url for retrieving an update manifest
+ * @param {string?} aAppVersion
+ * The optional application version to use for %APP_VERSION%
+ * @returns {string}
+ * The appropriately escaped URI.
+ */
+function escapeAddonURI(aAddon, aUri, aUpdateType, aAppVersion) {
+ let uri = AddonManager.escapeAddonURI(aAddon, aUri, aAppVersion);
+
+ // If there is an updateType then replace the UPDATE_TYPE string
+ if (aUpdateType) {
+ uri = uri.replace(/%UPDATE_TYPE%/g, aUpdateType);
+ }
+
+ // If this add-on has compatibility information for either the current
+ // application or toolkit then replace the ITEM_MAXAPPVERSION with the
+ // maxVersion
+ let app = aAddon.matchingTargetApplication;
+ if (app) {
+ var maxVersion = app.maxVersion;
+ } else {
+ maxVersion = "";
+ }
+ uri = uri.replace(/%ITEM_MAXAPPVERSION%/g, maxVersion);
+
+ let compatMode = "normal";
+ if (!AddonManager.checkCompatibility) {
+ compatMode = "ignore";
+ } else if (AddonManager.strictCompatibility) {
+ compatMode = "strict";
+ }
+ uri = uri.replace(/%COMPATIBILITY_MODE%/g, compatMode);
+
+ return uri;
+}
+
+/**
+ * Converts an iterable of addon objects into a map with the add-on's ID as key.
+ *
+ * @param {sequence<AddonInternal>} addons
+ * A sequence of AddonInternal objects.
+ *
+ * @returns {Map<string, AddonInternal>}
+ */
+function addonMap(addons) {
+ return new Map(addons.map(a => [a.id, a]));
+}
+
+async function removeAsync(aFile) {
+ await IOUtils.remove(aFile.path, { ignoreAbsent: true, recursive: true });
+}
+
+/**
+ * Recursively removes a directory or file fixing permissions when necessary.
+ *
+ * @param {nsIFile} aFile
+ * The nsIFile to remove
+ */
+function recursiveRemove(aFile) {
+ let isDir = null;
+
+ try {
+ isDir = aFile.isDirectory();
+ } catch (e) {
+ // If the file has already gone away then don't worry about it, this can
+ // happen on OSX where the resource fork is automatically moved with the
+ // data fork for the file. See bug 733436.
+ if (e.result == Cr.NS_ERROR_FILE_NOT_FOUND) {
+ return;
+ }
+
+ throw e;
+ }
+
+ setFilePermissions(
+ aFile,
+ isDir ? lazy.FileUtils.PERMS_DIRECTORY : lazy.FileUtils.PERMS_FILE
+ );
+
+ try {
+ aFile.remove(true);
+ return;
+ } catch (e) {
+ if (!aFile.isDirectory() || aFile.isSymlink()) {
+ logger.error("Failed to remove file " + aFile.path, e);
+ throw e;
+ }
+ }
+
+ // Use a snapshot of the directory contents to avoid possible issues with
+ // iterating over a directory while removing files from it (the YAFFS2
+ // embedded filesystem has this issue, see bug 772238), and to remove
+ // normal files before their resource forks on OSX (see bug 733436).
+ let entries = Array.from(XPIExports.XPIInternal.iterDirectory(aFile));
+ entries.forEach(recursiveRemove);
+
+ try {
+ aFile.remove(true);
+ } catch (e) {
+ logger.error("Failed to remove empty directory " + aFile.path, e);
+ throw e;
+ }
+}
+
+/**
+ * Sets permissions on a file
+ *
+ * @param {nsIFile} aFile
+ * The file or directory to operate on.
+ * @param {integer} aPermissions
+ * The permissions to set
+ */
+function setFilePermissions(aFile, aPermissions) {
+ try {
+ aFile.permissions = aPermissions;
+ } catch (e) {
+ logger.warn(
+ "Failed to set permissions " +
+ aPermissions.toString(8) +
+ " on " +
+ aFile.path,
+ e
+ );
+ }
+}
+
+/**
+ * Write a given string to a file
+ *
+ * @param {nsIFile} file
+ * The nsIFile instance to write into
+ * @param {string} string
+ * The string to write
+ */
+function writeStringToFile(file, string) {
+ let fileStream = new FileOutputStream(
+ file,
+ lazy.FileUtils.MODE_WRONLY |
+ lazy.FileUtils.MODE_CREATE |
+ lazy.FileUtils.MODE_TRUNCATE,
+ lazy.FileUtils.PERMS_FILE,
+ 0
+ );
+
+ try {
+ let binStream = new BinaryOutputStream(fileStream);
+
+ binStream.writeByteArray(new TextEncoder().encode(string));
+ } finally {
+ fileStream.close();
+ }
+}
+
+/**
+ * A safe way to install a file or the contents of a directory to a new
+ * directory. The file or directory is moved or copied recursively and if
+ * anything fails an attempt is made to rollback the entire operation. The
+ * operation may also be rolled back to its original state after it has
+ * completed by calling the rollback method.
+ *
+ * Operations can be chained. Calling move or copy multiple times will remember
+ * the whole set and if one fails all of the operations will be rolled back.
+ */
+function SafeInstallOperation() {
+ this._installedFiles = [];
+ this._createdDirs = [];
+}
+
+SafeInstallOperation.prototype = {
+ _installedFiles: null,
+ _createdDirs: null,
+
+ _installFile(aFile, aTargetDirectory, aCopy) {
+ let oldFile = aCopy ? null : aFile.clone();
+ let newFile = aFile.clone();
+ try {
+ if (aCopy) {
+ newFile.copyTo(aTargetDirectory, null);
+ // copyTo does not update the nsIFile with the new.
+ newFile = getFile(aFile.leafName, aTargetDirectory);
+ // Windows roaming profiles won't properly sync directories if a new file
+ // has an older lastModifiedTime than a previous file, so update.
+ newFile.lastModifiedTime = Date.now();
+ } else {
+ newFile.moveTo(aTargetDirectory, null);
+ }
+ } catch (e) {
+ logger.error(
+ "Failed to " +
+ (aCopy ? "copy" : "move") +
+ " file " +
+ aFile.path +
+ " to " +
+ aTargetDirectory.path,
+ e
+ );
+ throw e;
+ }
+ this._installedFiles.push({ oldFile, newFile });
+ },
+
+ /**
+ * Moves a file or directory into a new directory. If an error occurs then all
+ * files that have been moved will be moved back to their original location.
+ *
+ * @param {nsIFile} aFile
+ * The file or directory to be moved.
+ * @param {nsIFile} aTargetDirectory
+ * The directory to move into, this is expected to be an empty
+ * directory.
+ */
+ moveUnder(aFile, aTargetDirectory) {
+ try {
+ this._installFile(aFile, aTargetDirectory, false);
+ } catch (e) {
+ this.rollback();
+ throw e;
+ }
+ },
+
+ /**
+ * Renames a file to a new location. If an error occurs then all
+ * files that have been moved will be moved back to their original location.
+ *
+ * @param {nsIFile} aOldLocation
+ * The old location of the file.
+ * @param {nsIFile} aNewLocation
+ * The new location of the file.
+ */
+ moveTo(aOldLocation, aNewLocation) {
+ try {
+ let oldFile = aOldLocation.clone(),
+ newFile = aNewLocation.clone();
+ oldFile.moveTo(newFile.parent, newFile.leafName);
+ this._installedFiles.push({ oldFile, newFile, isMoveTo: true });
+ } catch (e) {
+ this.rollback();
+ throw e;
+ }
+ },
+
+ /**
+ * Copies a file or directory into a new directory. If an error occurs then
+ * all new files that have been created will be removed.
+ *
+ * @param {nsIFile} aFile
+ * The file or directory to be copied.
+ * @param {nsIFile} aTargetDirectory
+ * The directory to copy into, this is expected to be an empty
+ * directory.
+ */
+ copy(aFile, aTargetDirectory) {
+ try {
+ this._installFile(aFile, aTargetDirectory, true);
+ } catch (e) {
+ this.rollback();
+ throw e;
+ }
+ },
+
+ /**
+ * Rolls back all the moves that this operation performed. If an exception
+ * occurs here then both old and new directories are left in an indeterminate
+ * state
+ */
+ rollback() {
+ while (this._installedFiles.length) {
+ let move = this._installedFiles.pop();
+ if (move.isMoveTo) {
+ move.newFile.moveTo(move.oldDir.parent, move.oldDir.leafName);
+ } else if (move.newFile.isDirectory() && !move.newFile.isSymlink()) {
+ let oldDir = getFile(move.oldFile.leafName, move.oldFile.parent);
+ oldDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ } else if (!move.oldFile) {
+ // No old file means this was a copied file
+ move.newFile.remove(true);
+ } else {
+ move.newFile.moveTo(move.oldFile.parent, null);
+ }
+ }
+
+ while (this._createdDirs.length) {
+ recursiveRemove(this._createdDirs.pop());
+ }
+ },
+};
+
+// A hash algorithm if the caller of AddonInstall did not specify one.
+const DEFAULT_HASH_ALGO = "sha256";
+
+/**
+ * Base class for objects that manage the installation of an addon.
+ * This class isn't instantiated directly, see the derived classes below.
+ */
+class AddonInstall {
+ /**
+ * Instantiates an AddonInstall.
+ *
+ * @param {XPIStateLocation} installLocation
+ * The install location the add-on will be installed into
+ * @param {nsIURL} url
+ * The nsIURL to get the add-on from. If this is an nsIFileURL then
+ * the add-on will not need to be downloaded
+ * @param {Object} [options = {}]
+ * Additional options for the install
+ * @param {string} [options.hash]
+ * An optional hash for the add-on
+ * @param {AddonInternal} [options.existingAddon]
+ * The add-on this install will update if known
+ * @param {string} [options.name]
+ * An optional name for the add-on
+ * @param {string} [options.type]
+ * An optional type for the add-on
+ * @param {object} [options.icons]
+ * Optional icons for the add-on
+ * @param {string} [options.version]
+ * The expected version for the add-on.
+ * Required for updates, i.e. when existingAddon is set.
+ * @param {Object?} [options.telemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @param {boolean} [options.isUserRequestedUpdate]
+ * An optional boolean, true if the install object is related to a user triggered update.
+ * @param {nsIURL} [options.releaseNotesURI]
+ * An optional nsIURL that release notes where release notes can be retrieved.
+ * @param {function(string) : Promise<void>} [options.promptHandler]
+ * A callback to prompt the user before installing.
+ */
+ constructor(installLocation, url, options = {}) {
+ this.wrapper = new AddonInstallWrapper(this);
+ this.location = installLocation;
+ this.sourceURI = url;
+
+ if (options.hash) {
+ let hashSplit = options.hash.toLowerCase().split(":");
+ this.originalHash = {
+ algorithm: hashSplit[0],
+ data: hashSplit[1],
+ };
+ }
+ this.hash = this.originalHash;
+ this.fileHash = null;
+ this.existingAddon = options.existingAddon || null;
+ this.promptHandler = options.promptHandler || (() => Promise.resolve());
+ this.releaseNotesURI = options.releaseNotesURI || null;
+
+ this._startupPromise = null;
+
+ this._installPromise = new Promise(resolve => {
+ this._resolveInstallPromise = resolve;
+ });
+ // Ignore uncaught rejections for this promise, since they're
+ // handled by install listeners.
+ this._installPromise.catch(() => {});
+
+ this.listeners = [];
+ this.icons = options.icons || {};
+ this.error = 0;
+
+ this.progress = 0;
+ this.maxProgress = -1;
+
+ // Giving each instance of AddonInstall a reference to the logger.
+ this.logger = logger;
+
+ this.name = options.name || null;
+ this.type = options.type || null;
+ this.version = options.version || null;
+ this.isUserRequestedUpdate = options.isUserRequestedUpdate;
+ this.installTelemetryInfo = null;
+
+ if (options.telemetryInfo) {
+ this.installTelemetryInfo = options.telemetryInfo;
+ } else if (this.existingAddon) {
+ // Inherits the installTelemetryInfo on updates (so that the source of the original
+ // installation telemetry data is being preserved across the extension updates).
+ this.installTelemetryInfo = this.existingAddon.installTelemetryInfo;
+ this.existingAddon._updateInstall = this;
+ }
+
+ this.file = null;
+ this.ownsTempFile = null;
+
+ this.addon = null;
+ this.state = null;
+
+ XPIInstall.installs.add(this);
+ }
+
+ /**
+ * Called when we are finished with this install and are ready to remove
+ * any external references to it.
+ */
+ _cleanup() {
+ XPIInstall.installs.delete(this);
+ if (this.addon && this.addon._install) {
+ if (this.addon._install === this) {
+ this.addon._install = null;
+ } else {
+ Cu.reportError(new Error("AddonInstall mismatch"));
+ }
+ }
+ if (this.existingAddon && this.existingAddon._updateInstall) {
+ if (this.existingAddon._updateInstall === this) {
+ this.existingAddon._updateInstall = null;
+ } else {
+ Cu.reportError(new Error("AddonInstall existingAddon mismatch"));
+ }
+ }
+ }
+
+ /**
+ * Starts installation of this add-on from whatever state it is currently at
+ * if possible.
+ *
+ * Note this method is overridden to handle additional state in
+ * the subclassses below.
+ *
+ * @returns {Promise<Addon>}
+ * @throws if installation cannot proceed from the current state
+ */
+ install() {
+ switch (this.state) {
+ case AddonManager.STATE_DOWNLOADED:
+ this.checkPrompt();
+ break;
+ case AddonManager.STATE_PROMPTS_DONE:
+ this.checkForBlockers();
+ break;
+ case AddonManager.STATE_READY:
+ this.startInstall();
+ break;
+ case AddonManager.STATE_POSTPONED:
+ logger.debug(`Postponing install of ${this.addon.id}`);
+ break;
+ case AddonManager.STATE_DOWNLOADING:
+ case AddonManager.STATE_CHECKING_UPDATE:
+ case AddonManager.STATE_INSTALLING:
+ // Installation is already running
+ break;
+ default:
+ throw new Error("Cannot start installing from this state");
+ }
+ return this._installPromise;
+ }
+
+ continuePostponedInstall() {
+ if (this.state !== AddonManager.STATE_POSTPONED) {
+ throw new Error("AddonInstall not in postponed state");
+ }
+
+ // Force the postponed install to continue.
+ logger.info(`${this.addon.id} has resumed a previously postponed upgrade`);
+ this.state = AddonManager.STATE_READY;
+ this.install();
+ }
+
+ /**
+ * Called during XPIProvider shutdown so that we can do any necessary
+ * pre-shutdown cleanup.
+ */
+ onShutdown() {
+ switch (this.state) {
+ case AddonManager.STATE_POSTPONED:
+ this.removeTemporaryFile();
+ break;
+ }
+ }
+
+ /**
+ * Cancels installation of this add-on.
+ *
+ * Note this method is overridden to handle additional state in
+ * the subclass DownloadAddonInstall.
+ *
+ * @throws if installation cannot be cancelled from the current state
+ */
+ cancel() {
+ switch (this.state) {
+ case AddonManager.STATE_AVAILABLE:
+ case AddonManager.STATE_DOWNLOADED:
+ logger.debug("Cancelling download of " + this.sourceURI.spec);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners("onDownloadCancelled");
+ this.removeTemporaryFile();
+ break;
+ case AddonManager.STATE_POSTPONED:
+ logger.debug(`Cancelling postponed install of ${this.addon.id}`);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners(
+ "onInstallCancelled",
+ /* aCancelledByUser */ false
+ );
+ this.removeTemporaryFile();
+
+ let stagingDir = this.location.installer.getStagingDir();
+ let stagedAddon = stagingDir.clone();
+
+ this.unstageInstall(stagedAddon);
+ break;
+ default:
+ throw new Error(
+ "Cannot cancel install of " +
+ this.sourceURI.spec +
+ " from this state (" +
+ this.state +
+ ")"
+ );
+ }
+ }
+
+ /**
+ * Adds an InstallListener for this instance if the listener is not already
+ * registered.
+ *
+ * @param {InstallListener} aListener
+ * The InstallListener to add
+ */
+ addListener(aListener) {
+ if (
+ !this.listeners.some(function (i) {
+ return i == aListener;
+ })
+ ) {
+ this.listeners.push(aListener);
+ }
+ }
+
+ /**
+ * Removes an InstallListener for this instance if it is registered.
+ *
+ * @param {InstallListener} aListener
+ * The InstallListener to remove
+ */
+ removeListener(aListener) {
+ this.listeners = this.listeners.filter(function (i) {
+ return i != aListener;
+ });
+ }
+
+ /**
+ * Removes the temporary file owned by this AddonInstall if there is one.
+ */
+ removeTemporaryFile() {
+ // Only proceed if this AddonInstall owns its XPI file
+ if (!this.ownsTempFile) {
+ this.logger.debug(
+ `removeTemporaryFile: ${this.sourceURI.spec} does not own temp file`
+ );
+ return;
+ }
+
+ try {
+ this.logger.debug(
+ `removeTemporaryFile: ${this.sourceURI.spec} removing temp file ` +
+ this.file.path
+ );
+ flushJarCache(this.file);
+ this.file.remove(true);
+ this.ownsTempFile = false;
+ } catch (e) {
+ this.logger.warn(
+ `Failed to remove temporary file ${this.file.path} for addon ` +
+ this.sourceURI.spec,
+ e
+ );
+ }
+ }
+
+ _setFileHash(calculatedHash) {
+ this.fileHash = {
+ algorithm: this.hash ? this.hash.algorithm : DEFAULT_HASH_ALGO,
+ data: calculatedHash,
+ };
+
+ if (this.hash && calculatedHash != this.hash.data) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Updates the addon metadata that has to be propagated across restarts.
+ */
+ updatePersistedMetadata() {
+ this.addon.sourceURI = this.sourceURI.spec;
+
+ if (this.releaseNotesURI) {
+ this.addon.releaseNotesURI = this.releaseNotesURI.spec;
+ }
+
+ if (this.installTelemetryInfo) {
+ this.addon.installTelemetryInfo = this.installTelemetryInfo;
+ }
+ }
+
+ /**
+ * Called after the add-on is a local file and the signature and install
+ * manifest can be read.
+ *
+ * @param {nsIFile} file
+ * The file from which to load the manifest.
+ * @returns {Promise<void>}
+ */
+ async loadManifest(file) {
+ let pkg;
+ try {
+ pkg = Package.get(file);
+ } catch (e) {
+ return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]);
+ }
+
+ try {
+ try {
+ this.addon = await loadManifest(pkg, this.location, this.existingAddon);
+ } catch (e) {
+ return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]);
+ }
+
+ if (!this.addon.id) {
+ let msg = `Cannot find id for addon ${file.path}.`;
+ if (Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)) {
+ msg += ` Preference ${PREF_XPI_SIGNATURES_DEV_ROOT} is set.`;
+ }
+
+ return Promise.reject([
+ AddonManager.ERROR_CORRUPT_FILE,
+ new Error(msg),
+ ]);
+ }
+
+ if (
+ AppConstants.platform == "android" &&
+ this.addon.type !== "extension"
+ ) {
+ return Promise.reject([
+ AddonManager.ERROR_UNSUPPORTED_ADDON_TYPE,
+ `Unsupported add-on type: ${this.addon.type}`,
+ ]);
+ }
+
+ if (this.existingAddon) {
+ // Check various conditions related to upgrades
+ if (this.addon.id != this.existingAddon.id) {
+ return Promise.reject([
+ AddonManager.ERROR_INCORRECT_ID,
+ `Refusing to upgrade addon ${this.existingAddon.id} to different ID ${this.addon.id}`,
+ ]);
+ }
+
+ if (this.existingAddon.isWebExtension && !this.addon.isWebExtension) {
+ // This condition is never met on regular Firefox builds.
+ // Remove it along with externalExtensionLoaders (bug 1674799).
+ return Promise.reject([
+ AddonManager.ERROR_UNEXPECTED_ADDON_TYPE,
+ "WebExtensions may not be updated to other extension types",
+ ]);
+ }
+ if (this.existingAddon.type != this.addon.type) {
+ return Promise.reject([
+ AddonManager.ERROR_UNEXPECTED_ADDON_TYPE,
+ `Refusing to change addon type from ${this.existingAddon.type} to ${this.addon.type}`,
+ ]);
+ }
+
+ if (this.version !== this.addon.version) {
+ return Promise.reject([
+ AddonManager.ERROR_UNEXPECTED_ADDON_VERSION,
+ `Expected addon version ${this.version} instead of ${this.addon.version}`,
+ ]);
+ }
+ }
+
+ if (XPIExports.XPIDatabase.mustSign(this.addon.type)) {
+ if (this.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING) {
+ // This add-on isn't properly signed by a signature that chains to the
+ // trusted root.
+ let state = this.addon.signedState;
+ this.addon = null;
+
+ if (state == AddonManager.SIGNEDSTATE_MISSING) {
+ return Promise.reject([
+ AddonManager.ERROR_SIGNEDSTATE_REQUIRED,
+ "signature is required but missing",
+ ]);
+ }
+
+ return Promise.reject([
+ AddonManager.ERROR_CORRUPT_FILE,
+ "signature verification failed",
+ ]);
+ }
+ }
+ } finally {
+ pkg.close();
+ }
+
+ this.updatePersistedMetadata();
+
+ this.addon._install = this;
+ this.name = this.addon.selectedLocale.name;
+ this.type = this.addon.type;
+ this.version = this.addon.version;
+
+ // Setting the iconURL to something inside the XPI locks the XPI and
+ // makes it impossible to delete on Windows.
+
+ // Try to load from the existing cache first
+ let repoAddon = await lazy.AddonRepository.getCachedAddonByID(
+ this.addon.id
+ );
+
+ // It wasn't there so try to re-download it
+ if (!repoAddon) {
+ try {
+ [repoAddon] = await lazy.AddonRepository.cacheAddons([this.addon.id]);
+ } catch (err) {
+ logger.debug(
+ `Error getting metadata for ${this.addon.id}: ${err.message}`
+ );
+ }
+ }
+
+ this.addon._repositoryAddon = repoAddon;
+ this.name = this.name || this.addon._repositoryAddon.name;
+ this.addon.appDisabled = !XPIExports.XPIDatabase.isUsableAddon(this.addon);
+ return undefined;
+ }
+
+ getIcon(desiredSize = 64) {
+ if (!this.addon.icons || !this.file) {
+ return null;
+ }
+
+ let { icon } = lazy.IconDetails.getPreferredIcon(
+ this.addon.icons,
+ null,
+ desiredSize
+ );
+ if (icon.startsWith("chrome://")) {
+ return icon;
+ }
+ return getJarURI(this.file, icon).spec;
+ }
+
+ /**
+ * This method should be called when the XPI is ready to be installed,
+ * i.e., when a download finishes or when a local file has been verified.
+ * It should only be called from install() when the install is in
+ * STATE_DOWNLOADED (which actually means that the file is available
+ * and has been verified).
+ */
+ checkPrompt() {
+ (async () => {
+ if (this.promptHandler) {
+ let info = {
+ existingAddon: this.existingAddon ? this.existingAddon.wrapper : null,
+ addon: this.addon.wrapper,
+ icon: this.getIcon(),
+ // Used in AMTelemetry to detect the install flow related to this prompt.
+ install: this.wrapper,
+ };
+
+ try {
+ await this.promptHandler(info);
+ } catch (err) {
+ if (this.error < 0) {
+ logger.info(`Install of ${this.addon.id} failed ${this.error}`);
+ this.state = AddonManager.STATE_INSTALL_FAILED;
+ this._cleanup();
+ // In some cases onOperationCancelled is called during failures
+ // to install/uninstall/enable/disable addons. We may need to
+ // do that here in the future.
+ this._callInstallListeners("onInstallFailed");
+ this.removeTemporaryFile();
+ } else {
+ logger.info(`Install of ${this.addon.id} cancelled by user`);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners(
+ "onInstallCancelled",
+ /* aCancelledByUser */ true
+ );
+ }
+ return;
+ }
+ }
+ this.state = AddonManager.STATE_PROMPTS_DONE;
+ this.install();
+ })();
+ }
+
+ /**
+ * This method should be called when we have the XPI and any needed
+ * permissions prompts have been completed. If there are any upgrade
+ * listeners, they are invoked and the install moves into STATE_POSTPONED.
+ * Otherwise, the install moves into STATE_INSTALLING
+ */
+ checkForBlockers() {
+ // If an upgrade listener is registered for this add-on, pass control
+ // over the upgrade to the add-on.
+ if (AddonManagerPrivate.hasUpgradeListener(this.addon.id)) {
+ logger.info(
+ `add-on ${this.addon.id} has an upgrade listener, postponing upgrade until restart`
+ );
+ let resumeFn = () => {
+ this.continuePostponedInstall();
+ };
+ this.postpone(resumeFn);
+ return;
+ }
+
+ this.state = AddonManager.STATE_READY;
+ this.install();
+ }
+
+ /**
+ * Installs the add-on into the install location.
+ */
+ async startInstall() {
+ this.state = AddonManager.STATE_INSTALLING;
+ if (!this._callInstallListeners("onInstallStarted")) {
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this.removeTemporaryFile();
+ this._cleanup();
+ this._callInstallListeners(
+ "onInstallCancelled",
+ /* aCancelledByUser */ false
+ );
+ return;
+ }
+
+ // Reinstall existing user-disabled addon (of the same installed version).
+ // If addon is marked to be uninstalled - don't reinstall it.
+ if (
+ this.existingAddon &&
+ this.existingAddon.location === this.location &&
+ this.existingAddon.version === this.addon.version &&
+ this.existingAddon.userDisabled &&
+ !this.existingAddon.pendingUninstall
+ ) {
+ await XPIExports.XPIDatabase.updateAddonDisabledState(
+ this.existingAddon,
+ {
+ userDisabled: false,
+ }
+ );
+ this.state = AddonManager.STATE_INSTALLED;
+ this._callInstallListeners("onInstallEnded", this.existingAddon.wrapper);
+ this._cleanup();
+ return;
+ }
+
+ let isSameLocation = this.existingAddon?.location == this.location;
+ let willActivate =
+ isSameLocation ||
+ !this.existingAddon ||
+ this.location.hasPrecedence(this.existingAddon.location);
+
+ logger.debug(
+ "Starting install of " + this.addon.id + " from " + this.sourceURI.spec
+ );
+ AddonManagerPrivate.callAddonListeners(
+ "onInstalling",
+ this.addon.wrapper,
+ false
+ );
+
+ let stagedAddon = this.location.installer.getStagingDir();
+
+ try {
+ await this.location.installer.requestStagingDir();
+
+ // remove any previously staged files
+ await this.unstageInstall(stagedAddon);
+
+ stagedAddon.append(`${this.addon.id}.xpi`);
+
+ await this.stageInstall(false, stagedAddon, isSameLocation);
+
+ this._cleanup();
+
+ let install = async () => {
+ // Mark this instance of the addon as inactive if it is being
+ // superseded by an addon in a different location.
+ if (
+ willActivate &&
+ this.existingAddon &&
+ this.existingAddon.active &&
+ !isSameLocation
+ ) {
+ XPIExports.XPIDatabase.updateAddonActive(this.existingAddon, false);
+ }
+
+ // Install the new add-on into its final location
+ let file = await this.location.installer.installAddon({
+ id: this.addon.id,
+ source: stagedAddon,
+ });
+
+ // Update the metadata in the database
+ this.addon.sourceBundle = file;
+ // If this addon will be the active addon, make it visible.
+ this.addon.visible = willActivate;
+
+ if (isSameLocation) {
+ this.addon = XPIExports.XPIDatabase.updateAddonMetadata(
+ this.existingAddon,
+ this.addon,
+ file.path
+ );
+ let state = this.location.get(this.addon.id);
+ if (state) {
+ state.syncWithDB(this.addon, true);
+ } else {
+ logger.warn(
+ "Unexpected missing XPI state for add-on ${id}",
+ this.addon
+ );
+ }
+ } else {
+ this.addon.active = this.addon.visible && !this.addon.disabled;
+ this.addon = XPIExports.XPIDatabase.addToDatabase(
+ this.addon,
+ file.path
+ );
+ XPIExports.XPIInternal.XPIStates.addAddon(this.addon);
+ this.addon.installDate = this.addon.updateDate;
+ XPIExports.XPIDatabase.saveChanges();
+ }
+ XPIExports.XPIInternal.XPIStates.save();
+
+ AddonManagerPrivate.callAddonListeners(
+ "onInstalled",
+ this.addon.wrapper
+ );
+
+ logger.debug(`Install of ${this.sourceURI.spec} completed.`);
+ this.state = AddonManager.STATE_INSTALLED;
+ this._callInstallListeners("onInstallEnded", this.addon.wrapper);
+
+ XPIExports.XPIDatabase.recordAddonTelemetry(this.addon);
+
+ // Notify providers that a new theme has been enabled.
+ if (this.addon.type === "theme" && this.addon.active) {
+ AddonManagerPrivate.notifyAddonChanged(
+ this.addon.id,
+ this.addon.type
+ );
+ }
+
+ // Clear the colorways builtins migrated to a non-builtin themes
+ // form the list of the retained themes.
+ if (
+ this.existingAddon?.isBuiltinColorwayTheme &&
+ !this.addon.isBuiltin &&
+ XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ ) {
+ XPIExports.BuiltInThemesHelpers.unretainMigratedColorwayTheme(
+ this.addon.id
+ );
+ }
+ };
+
+ this._startupPromise = (async () => {
+ if (!willActivate) {
+ await install();
+ } else if (this.existingAddon) {
+ await XPIExports.XPIInternal.BootstrapScope.get(
+ this.existingAddon
+ ).update(this.addon, !this.addon.disabled, install);
+
+ if (this.addon.disabled) {
+ flushJarCache(this.file);
+ }
+ } else {
+ await install();
+ await XPIExports.XPIInternal.BootstrapScope.get(this.addon).install(
+ undefined,
+ true
+ );
+ }
+ })();
+
+ await this._startupPromise;
+ } catch (e) {
+ logger.warn(
+ `Failed to install ${this.file.path} from ${this.sourceURI.spec} to ${stagedAddon.path}`,
+ e
+ );
+
+ if (stagedAddon.exists()) {
+ recursiveRemove(stagedAddon);
+ }
+ this.state = AddonManager.STATE_INSTALL_FAILED;
+ this.error = AddonManager.ERROR_FILE_ACCESS;
+ this._cleanup();
+ AddonManagerPrivate.callAddonListeners(
+ "onOperationCancelled",
+ this.addon.wrapper
+ );
+ this._callInstallListeners("onInstallFailed");
+ } finally {
+ this.removeTemporaryFile();
+ this.location.installer.releaseStagingDir();
+ }
+ }
+
+ /**
+ * Stages an add-on for install.
+ *
+ * @param {boolean} restartRequired
+ * If true, the final installation will be deferred until the
+ * next app startup.
+ * @param {nsIFile} stagedAddon
+ * The file where the add-on should be staged.
+ * @param {boolean} isSameLocation
+ * True if this installation is an upgrade for an existing
+ * add-on in the same location.
+ * @throws if the file cannot be staged.
+ */
+ async stageInstall(restartRequired, stagedAddon, isSameLocation) {
+ logger.debug(`Addon ${this.addon.id} will be installed as a packed xpi`);
+ stagedAddon.leafName = `${this.addon.id}.xpi`;
+
+ try {
+ await IOUtils.copy(this.file.path, stagedAddon.path);
+
+ let calculatedHash = getHashForFile(stagedAddon, this.fileHash.algorithm);
+ if (calculatedHash != this.fileHash.data) {
+ logger.warn(
+ `Staged file hash (${calculatedHash}) did not match initial hash (${this.fileHash.data})`
+ );
+ throw new Error("Refusing to stage add-on because it has been damaged");
+ }
+ } catch (e) {
+ await IOUtils.remove(stagedAddon.path, { ignoreAbsent: true });
+ throw e;
+ }
+
+ if (restartRequired) {
+ // Point the add-on to its extracted files as the xpi may get deleted
+ this.addon.sourceBundle = stagedAddon;
+
+ logger.debug(
+ `Staged install of ${this.addon.id} from ${this.sourceURI.spec} ready; waiting for restart.`
+ );
+ if (isSameLocation) {
+ delete this.existingAddon.pendingUpgrade;
+ this.existingAddon.pendingUpgrade = this.addon;
+ }
+ }
+
+ if (this.state === AddonManager.STATE_POSTPONED) {
+ // Cache the AddonInternal as it may have updated compatibility info. We
+ // do that unconditionally in case the staged install isn't finalized in
+ // the same session. That way, on the next app startup, the add-on will
+ // be installed.
+ this.location.stageAddon(this.addon.id, this.addon.toJSON());
+ }
+ }
+
+ /**
+ * Removes any previously staged upgrade.
+ *
+ * @param {nsIFile} stagingDir
+ * The staging directory from which to unstage the install.
+ */
+ async unstageInstall(stagingDir) {
+ this.location.unstageAddon(this.addon.id);
+
+ await removeAsync(getFile(this.addon.id, stagingDir));
+
+ await removeAsync(getFile(`${this.addon.id}.xpi`, stagingDir));
+ }
+
+ /**
+ * Postone a pending update, until restart or until the add-on resumes.
+ *
+ * @param {function} resumeFn
+ * A function for the add-on to run when resuming.
+ * @param {boolean} requiresRestart
+ * Whether this add-on requires restart.
+ */
+ async postpone(resumeFn, requiresRestart = true) {
+ this.state = AddonManager.STATE_POSTPONED;
+
+ let stagingDir = this.location.installer.getStagingDir();
+
+ try {
+ await this.location.installer.requestStagingDir();
+ await this.unstageInstall(stagingDir);
+
+ let stagedAddon = getFile(`${this.addon.id}.xpi`, stagingDir);
+
+ await this.stageInstall(requiresRestart, stagedAddon, true);
+ } catch (e) {
+ logger.warn(`Failed to postpone install of ${this.addon.id}`, e);
+ this.state = AddonManager.STATE_INSTALL_FAILED;
+ this.error = AddonManager.ERROR_FILE_ACCESS;
+ this._cleanup();
+ this.removeTemporaryFile();
+ this.location.installer.releaseStagingDir();
+ this._callInstallListeners("onInstallFailed");
+ return;
+ }
+
+ this._callInstallListeners("onInstallPostponed");
+
+ // upgrade has been staged for restart, provide a way for it to call the
+ // resume function.
+ let callback = AddonManagerPrivate.getUpgradeListener(this.addon.id);
+ if (callback) {
+ callback({
+ version: this.version,
+ install: () => {
+ switch (this.state) {
+ case AddonManager.STATE_POSTPONED:
+ if (resumeFn) {
+ resumeFn();
+ }
+ break;
+ default:
+ logger.warn(
+ `${this.addon.id} cannot resume postponed upgrade from state (${this.state})`
+ );
+ break;
+ }
+ },
+ });
+ }
+ // Release the staging directory lock, but since the staging dir is populated
+ // it will not be removed until resumed or installed by restart.
+ // See also cleanStagingDir()
+ this.location.installer.releaseStagingDir();
+ }
+
+ _callInstallListeners(event, ...args) {
+ switch (event) {
+ case "onDownloadCancelled":
+ case "onDownloadFailed":
+ case "onInstallCancelled":
+ case "onInstallFailed":
+ let rej = Promise.reject(new Error(`Install failed: ${event}`));
+ rej.catch(() => {});
+ this._resolveInstallPromise(rej);
+ break;
+ case "onInstallEnded":
+ this._resolveInstallPromise(
+ Promise.resolve(this._startupPromise).then(() => args[0])
+ );
+ break;
+ }
+ return AddonManagerPrivate.callInstallListeners(
+ event,
+ this.listeners,
+ this.wrapper,
+ ...args
+ );
+ }
+}
+
+var LocalAddonInstall = class extends AddonInstall {
+ /**
+ * Initialises this install to be an install from a local file.
+ */
+ async init() {
+ this.file = this.sourceURI.QueryInterface(Ci.nsIFileURL).file;
+
+ if (!this.file.exists()) {
+ logger.warn("XPI file " + this.file.path + " does not exist");
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_NETWORK_FAILURE;
+ this._cleanup();
+ return;
+ }
+
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this.progress = this.file.fileSize;
+ this.maxProgress = this.file.fileSize;
+
+ let algorithm = this.hash ? this.hash.algorithm : DEFAULT_HASH_ALGO;
+ if (this.hash) {
+ try {
+ CryptoHash(this.hash.algorithm);
+ } catch (e) {
+ logger.warn(
+ "Unknown hash algorithm '" +
+ this.hash.algorithm +
+ "' for addon " +
+ this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_INCORRECT_HASH;
+ this._cleanup();
+ return;
+ }
+ }
+
+ if (!this._setFileHash(getHashForFile(this.file, algorithm))) {
+ logger.warn(
+ `File hash (${this.fileHash.data}) did not match provided hash (${this.hash.data})`
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_INCORRECT_HASH;
+ this._cleanup();
+ return;
+ }
+
+ try {
+ await this.loadManifest(this.file);
+ } catch ([error, message]) {
+ logger.warn("Invalid XPI", message);
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = error;
+ this._cleanup();
+ this._callInstallListeners("onNewInstall");
+ flushJarCache(this.file);
+ return;
+ }
+
+ let addon = await XPIExports.XPIDatabase.getVisibleAddonForID(
+ this.addon.id
+ );
+
+ this.existingAddon = addon;
+ this.addon.propagateDisabledState(this.existingAddon);
+ await this.addon.updateBlocklistState();
+ this.addon.updateDate = Date.now();
+ this.addon.installDate = addon ? addon.installDate : this.addon.updateDate;
+
+ // Report if blocked add-on becomes unblocked through this install.
+ if (
+ addon?.blocklistState === nsIBlocklistService.STATE_BLOCKED &&
+ this.addon.blocklistState === nsIBlocklistService.STATE_NOT_BLOCKED
+ ) {
+ this.addon.recordAddonBlockChangeTelemetry("addon_install");
+ }
+
+ if (this.addon.blocklistState === nsIBlocklistService.STATE_BLOCKED) {
+ this.error = AddonManager.ERROR_BLOCKLISTED;
+ }
+
+ if (!this.addon.isCompatible) {
+ this.state = AddonManager.STATE_CHECKING_UPDATE;
+
+ await new Promise(resolve => {
+ new UpdateChecker(
+ this.addon,
+ {
+ onUpdateFinished: (aAddon, aError) => {
+ this.state = AddonManager.STATE_DOWNLOADED;
+ // If checking for an updated compatibility range fails or the
+ // add-on is still incompatible, then set the expected
+ // `install.error` to `ERROR_INCOMPATIBLE`.
+ if (!this.addon.isCompatible) {
+ this.error = AddonManager.ERROR_INCOMPATIBLE;
+ }
+ if (aError < 0) {
+ logger.warn(
+ `UpdateChecker failed to download updates for ${this.addon.id}, error code: ${aError}`
+ );
+ } else {
+ this._callInstallListeners("onNewInstall");
+ }
+ resolve();
+ },
+ },
+ AddonManager.UPDATE_WHEN_ADDON_INSTALLED
+ );
+ });
+ } else {
+ this._callInstallListeners("onNewInstall");
+ }
+ }
+
+ install() {
+ if (this.state == AddonManager.STATE_DOWNLOAD_FAILED) {
+ // For a local install, this state means that verification of the
+ // file failed (e.g., the hash or signature or manifest contents
+ // were invalid). It doesn't make sense to retry anything in this
+ // case but we have callers who don't know if their AddonInstall
+ // object is a local file or a download so accommodate them here.
+ this._callInstallListeners("onDownloadFailed");
+ return this._installPromise;
+ }
+ return super.install();
+ }
+};
+
+var DownloadAddonInstall = class extends AddonInstall {
+ /**
+ * Instantiates a DownloadAddonInstall
+ *
+ * @param {XPIStateLocation} installLocation
+ * The XPIStateLocation the add-on will be installed into
+ * @param {nsIURL} url
+ * The nsIURL to get the add-on from
+ * @param {Object} [options = {}]
+ * Additional options for the install
+ * @param {string} [options.hash]
+ * An optional hash for the add-on
+ * @param {AddonInternal} [options.existingAddon]
+ * The add-on this install will update if known
+ * @param {XULElement} [options.browser]
+ * The browser performing the install, used to display
+ * authentication prompts.
+ * @param {nsIPrincipal} [options.principal]
+ * The principal to use. If not present, will default to browser.contentPrincipal.
+ * @param {string} [options.name]
+ * An optional name for the add-on
+ * @param {string} [options.type]
+ * An optional type for the add-on
+ * @param {Object} [options.icons]
+ * Optional icons for the add-on
+ * @param {string} [options.version]
+ * The expected version for the add-on.
+ * Required for updates, i.e. when existingAddon is set.
+ * @param {function(string) : Promise<void>} [options.promptHandler]
+ * A callback to prompt the user before installing.
+ * @param {boolean} [options.sendCookies]
+ * Whether cookies should be sent when downloading the add-on.
+ */
+ constructor(installLocation, url, options = {}) {
+ super(installLocation, url, options);
+
+ this.browser = options.browser;
+ this.loadingPrincipal =
+ options.triggeringPrincipal ||
+ (this.browser && this.browser.contentPrincipal) ||
+ Services.scriptSecurityManager.getSystemPrincipal();
+ this.sendCookies = Boolean(options.sendCookies);
+
+ this.state = AddonManager.STATE_AVAILABLE;
+
+ this.stream = null;
+ this.crypto = null;
+ this.badCertHandler = null;
+ this.restartDownload = false;
+ this.downloadStartedAt = null;
+
+ this._callInstallListeners("onNewInstall", this.listeners, this.wrapper);
+ }
+
+ install() {
+ switch (this.state) {
+ case AddonManager.STATE_AVAILABLE:
+ this.startDownload();
+ break;
+ case AddonManager.STATE_DOWNLOAD_FAILED:
+ case AddonManager.STATE_INSTALL_FAILED:
+ case AddonManager.STATE_CANCELLED:
+ this.removeTemporaryFile();
+ this.state = AddonManager.STATE_AVAILABLE;
+ this.error = 0;
+ this.progress = 0;
+ this.maxProgress = -1;
+ this.hash = this.originalHash;
+ this.fileHash = null;
+ this.startDownload();
+ break;
+ default:
+ return super.install();
+ }
+ return this._installPromise;
+ }
+
+ cancel() {
+ // If we're done downloading the file but still processing it we cannot
+ // cancel the installation. We just call the base class which will handle
+ // the request by throwing an error.
+ if (this.channel && this.state == AddonManager.STATE_DOWNLOADING) {
+ logger.debug("Cancelling download of " + this.sourceURI.spec);
+ this.channel.cancel(Cr.NS_BINDING_ABORTED);
+ } else {
+ super.cancel();
+ }
+ }
+
+ observe(aSubject, aTopic, aData) {
+ // Network is going offline
+ this.cancel();
+ }
+
+ /**
+ * Starts downloading the add-on's XPI file.
+ */
+ startDownload() {
+ this.downloadStartedAt = Cu.now();
+
+ this.state = AddonManager.STATE_DOWNLOADING;
+ if (!this._callInstallListeners("onDownloadStarted")) {
+ logger.debug(
+ "onDownloadStarted listeners cancelled installation of addon " +
+ this.sourceURI.spec
+ );
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners("onDownloadCancelled");
+ return;
+ }
+
+ // If a listener changed our state then do not proceed with the download
+ if (this.state != AddonManager.STATE_DOWNLOADING) {
+ return;
+ }
+
+ if (this.channel) {
+ // A previous download attempt hasn't finished cleaning up yet, signal
+ // that it should restart when complete
+ logger.debug("Waiting for previous download to complete");
+ this.restartDownload = true;
+ return;
+ }
+
+ this.openChannel();
+ }
+
+ openChannel() {
+ this.restartDownload = false;
+
+ try {
+ this.file = getTemporaryFile();
+ this.ownsTempFile = true;
+ this.stream = new FileOutputStream(
+ this.file,
+ lazy.FileUtils.MODE_WRONLY |
+ lazy.FileUtils.MODE_CREATE |
+ lazy.FileUtils.MODE_TRUNCATE,
+ lazy.FileUtils.PERMS_FILE,
+ 0
+ );
+ } catch (e) {
+ logger.warn(
+ "Failed to start download for addon " + this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_FILE_ACCESS;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+ return;
+ }
+
+ let listener = Cc[
+ "@mozilla.org/network/stream-listener-tee;1"
+ ].createInstance(Ci.nsIStreamListenerTee);
+ listener.init(this, this.stream);
+ try {
+ this.badCertHandler = new lazy.CertUtils.BadCertHandler(
+ !lazy.AddonSettings.INSTALL_REQUIREBUILTINCERTS
+ );
+
+ this.channel = lazy.NetUtil.newChannel({
+ uri: this.sourceURI,
+ securityFlags:
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD,
+ loadingPrincipal: this.loadingPrincipal,
+ });
+ this.channel.notificationCallbacks = this;
+ if (this.sendCookies) {
+ if (this.channel instanceof Ci.nsIHttpChannelInternal) {
+ this.channel.forceAllowThirdPartyCookie = true;
+ }
+ } else {
+ this.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS;
+ }
+ this.channel.asyncOpen(listener);
+
+ Services.obs.addObserver(this, "network:offline-about-to-go-offline");
+ } catch (e) {
+ logger.warn(
+ "Failed to start download for addon " + this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_NETWORK_FAILURE;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+ }
+ }
+
+ /*
+ * Update the crypto hasher with the new data and call the progress listeners.
+ *
+ * @see nsIStreamListener
+ */
+ onDataAvailable(aRequest, aInputstream, aOffset, aCount) {
+ this.crypto.updateFromStream(aInputstream, aCount);
+ this.progress += aCount;
+ if (!this._callInstallListeners("onDownloadProgress")) {
+ // TODO cancel the download and make it available again (bug 553024)
+ }
+ }
+
+ /*
+ * Check the redirect response for a hash of the target XPI and verify that
+ * we don't end up on an insecure channel.
+ *
+ * @see nsIChannelEventSink
+ */
+ asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) {
+ if (
+ !this.hash &&
+ aOldChannel.originalURI.schemeIs("https") &&
+ aOldChannel instanceof Ci.nsIHttpChannel
+ ) {
+ try {
+ let hashStr = aOldChannel.getResponseHeader("X-Target-Digest");
+ let hashSplit = hashStr.toLowerCase().split(":");
+ this.hash = {
+ algorithm: hashSplit[0],
+ data: hashSplit[1],
+ };
+ } catch (e) {}
+ }
+
+ // Verify that we don't end up on an insecure channel if we haven't got a
+ // hash to verify with (see bug 537761 for discussion)
+ if (!this.hash) {
+ this.badCertHandler.asyncOnChannelRedirect(
+ aOldChannel,
+ aNewChannel,
+ aFlags,
+ aCallback
+ );
+ } else {
+ aCallback.onRedirectVerifyCallback(Cr.NS_OK);
+ }
+
+ this.channel = aNewChannel;
+ }
+
+ /*
+ * This is the first chance to get at real headers on the channel.
+ *
+ * @see nsIStreamListener
+ */
+ onStartRequest(aRequest) {
+ if (this.hash) {
+ try {
+ this.crypto = CryptoHash(this.hash.algorithm);
+ } catch (e) {
+ logger.warn(
+ "Unknown hash algorithm '" +
+ this.hash.algorithm +
+ "' for addon " +
+ this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_INCORRECT_HASH;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+ aRequest.cancel(Cr.NS_BINDING_ABORTED);
+ return;
+ }
+ } else {
+ // We always need something to consume data from the inputstream passed
+ // to onDataAvailable so just create a dummy cryptohasher to do that.
+ this.crypto = CryptoHash(DEFAULT_HASH_ALGO);
+ }
+
+ this.progress = 0;
+ if (aRequest instanceof Ci.nsIChannel) {
+ try {
+ this.maxProgress = aRequest.contentLength;
+ } catch (e) {}
+ logger.debug(
+ "Download started for " +
+ this.sourceURI.spec +
+ " to file " +
+ this.file.path
+ );
+ }
+ }
+
+ /*
+ * The download is complete.
+ *
+ * @see nsIStreamListener
+ */
+ onStopRequest(aRequest, aStatus) {
+ this.stream.close();
+ this.channel = null;
+ this.badCerthandler = null;
+ Services.obs.removeObserver(this, "network:offline-about-to-go-offline");
+
+ let crypto = this.crypto;
+ this.crypto = null;
+
+ // If the download was cancelled then update the state and send events
+ if (aStatus == Cr.NS_BINDING_ABORTED) {
+ if (this.state == AddonManager.STATE_DOWNLOADING) {
+ logger.debug("Cancelled download of " + this.sourceURI.spec);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners("onDownloadCancelled");
+ // If a listener restarted the download then there is no need to
+ // remove the temporary file
+ if (this.state != AddonManager.STATE_CANCELLED) {
+ return;
+ }
+ }
+
+ this.removeTemporaryFile();
+ if (this.restartDownload) {
+ this.openChannel();
+ }
+ return;
+ }
+
+ logger.debug("Download of " + this.sourceURI.spec + " completed.");
+
+ if (Components.isSuccessCode(aStatus)) {
+ if (
+ !(aRequest instanceof Ci.nsIHttpChannel) ||
+ aRequest.requestSucceeded
+ ) {
+ if (!this.hash && aRequest instanceof Ci.nsIChannel) {
+ try {
+ lazy.CertUtils.checkCert(
+ aRequest,
+ !lazy.AddonSettings.INSTALL_REQUIREBUILTINCERTS
+ );
+ } catch (e) {
+ this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, e);
+ return;
+ }
+ }
+
+ if (!this._setFileHash(getHashStringForCrypto(crypto))) {
+ this.downloadFailed(
+ AddonManager.ERROR_INCORRECT_HASH,
+ `Downloaded file hash (${this.fileHash.data}) did not match provided hash (${this.hash.data})`
+ );
+ return;
+ }
+
+ this.loadManifest(this.file).then(
+ () => {
+ if (this.addon.isCompatible) {
+ this.downloadCompleted();
+ } else {
+ // TODO Should we send some event here (bug 557716)?
+ this.state = AddonManager.STATE_CHECKING_UPDATE;
+ new UpdateChecker(
+ this.addon,
+ {
+ onUpdateFinished: aAddon => this.downloadCompleted(),
+ },
+ AddonManager.UPDATE_WHEN_ADDON_INSTALLED
+ );
+ }
+ },
+ ([error, message]) => {
+ this.removeTemporaryFile();
+ this.downloadFailed(error, message);
+ }
+ );
+ } else if (aRequest instanceof Ci.nsIHttpChannel) {
+ this.downloadFailed(
+ AddonManager.ERROR_NETWORK_FAILURE,
+ aRequest.responseStatus + " " + aRequest.responseStatusText
+ );
+ } else {
+ this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, aStatus);
+ }
+ } else {
+ this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, aStatus);
+ }
+ }
+
+ /**
+ * Notify listeners that the download failed.
+ *
+ * @param {string} aReason
+ * Something to log about the failure
+ * @param {integer} aError
+ * The error code to pass to the listeners
+ */
+ downloadFailed(aReason, aError) {
+ logger.warn("Download of " + this.sourceURI.spec + " failed", aError);
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = aReason;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+
+ // If the listener hasn't restarted the download then remove any temporary
+ // file
+ if (this.state == AddonManager.STATE_DOWNLOAD_FAILED) {
+ logger.debug(
+ "downloadFailed: removing temp file for " + this.sourceURI.spec
+ );
+ this.removeTemporaryFile();
+ } else {
+ logger.debug(
+ "downloadFailed: listener changed AddonInstall state for " +
+ this.sourceURI.spec +
+ " to " +
+ this.state
+ );
+ }
+ }
+
+ /**
+ * Notify listeners that the download completed.
+ */
+ async downloadCompleted() {
+ let wasUpdate = !!this.existingAddon;
+ let aAddon = await XPIExports.XPIDatabase.getVisibleAddonForID(
+ this.addon.id
+ );
+ if (aAddon) {
+ this.existingAddon = aAddon;
+ }
+
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this.addon.updateDate = Date.now();
+
+ if (this.existingAddon) {
+ this.addon.installDate = this.existingAddon.installDate;
+ } else {
+ this.addon.installDate = this.addon.updateDate;
+ }
+ this.addon.propagateDisabledState(this.existingAddon);
+ await this.addon.updateBlocklistState();
+
+ // Report if blocked add-on becomes unblocked through this install/update.
+ if (
+ aAddon?.blocklistState === nsIBlocklistService.STATE_BLOCKED &&
+ this.addon.blocklistState === nsIBlocklistService.STATE_NOT_BLOCKED
+ ) {
+ this.addon.recordAddonBlockChangeTelemetry(
+ wasUpdate ? "addon_update" : "addon_install"
+ );
+ }
+
+ if (this.addon.blocklistState === nsIBlocklistService.STATE_BLOCKED) {
+ this.error = AddonManager.ERROR_BLOCKLISTED;
+ } else if (!this.addon.isCompatible) {
+ this.error = AddonManager.ERROR_INCOMPATIBLE;
+ }
+
+ if (this._callInstallListeners("onDownloadEnded")) {
+ // If a listener changed our state then do not proceed with the install
+ if (this.state != AddonManager.STATE_DOWNLOADED) {
+ return;
+ }
+
+ // proceed with the install state machine.
+ this.install();
+ }
+ }
+
+ getInterface(iid) {
+ if (iid.equals(Ci.nsIAuthPrompt2)) {
+ let win = null;
+ if (this.browser) {
+ win = this.browser.contentWindow || this.browser.ownerGlobal;
+ }
+
+ let factory = Cc["@mozilla.org/prompter;1"].getService(
+ Ci.nsIPromptFactory
+ );
+ let prompt = factory.getPrompt(win, Ci.nsIAuthPrompt2);
+
+ if (this.browser && prompt instanceof Ci.nsILoginManagerAuthPrompter) {
+ prompt.browser = this.browser;
+ }
+
+ return prompt;
+ } else if (iid.equals(Ci.nsIChannelEventSink)) {
+ return this;
+ }
+
+ return this.badCertHandler.getInterface(iid);
+ }
+};
+
+/**
+ * Creates a new AddonInstall for an update.
+ *
+ * @param {function} aCallback
+ * The callback to pass the new AddonInstall to
+ * @param {AddonInternal} aAddon
+ * The add-on being updated
+ * @param {Object} aUpdate
+ * The metadata about the new version from the update manifest
+ * @param {boolean} isUserRequested
+ * An optional boolean, true if the install object is related to a user triggered update.
+ */
+function createUpdate(aCallback, aAddon, aUpdate, isUserRequested) {
+ let url = Services.io.newURI(aUpdate.updateURL);
+
+ (async function () {
+ let opts = {
+ hash: aUpdate.updateHash,
+ existingAddon: aAddon,
+ name: aAddon.selectedLocale.name,
+ type: aAddon.type,
+ icons: aAddon.icons,
+ version: aUpdate.version,
+ isUserRequestedUpdate: isUserRequested,
+ };
+
+ try {
+ if (aUpdate.updateInfoURL) {
+ opts.releaseNotesURI = Services.io.newURI(
+ escapeAddonURI(aAddon, aUpdate.updateInfoURL)
+ );
+ }
+ } catch (e) {
+ // If the releaseNotesURI cannot be parsed then just ignore it.
+ }
+
+ let install;
+ if (url instanceof Ci.nsIFileURL) {
+ install = new LocalAddonInstall(aAddon.location, url, opts);
+ await install.init();
+ } else {
+ let loc = aAddon.location;
+ if (
+ aAddon.isBuiltinColorwayTheme &&
+ XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ ) {
+ // Builtin colorways theme needs to be updated by installing the version
+ // got from AMO into the profile location and not using the location
+ // where the builtin addon is currently installed.
+ logger.info(
+ `Overriding location to APP_PROFILE on builtin colorway theme update for "${aAddon.id}"`
+ );
+ loc = XPIExports.XPIInternal.XPIStates.getLocation(
+ XPIExports.XPIInternal.KEY_APP_PROFILE
+ );
+ }
+ install = new DownloadAddonInstall(loc, url, opts);
+ }
+
+ aCallback(install);
+ })();
+}
+
+// Maps instances of AddonInstall to AddonInstallWrapper
+const wrapperMap = new WeakMap();
+let installFor = wrapper => wrapperMap.get(wrapper);
+
+// Numeric id included in the install telemetry events to correlate multiple events related
+// to the same install or update flow.
+let nextInstallId = 0;
+
+/**
+ * Creates a wrapper for an AddonInstall that only exposes the public API
+ *
+ * @param {AddonInstall} aInstall
+ * The AddonInstall to create a wrapper for
+ */
+function AddonInstallWrapper(aInstall) {
+ wrapperMap.set(this, aInstall);
+ this.installId = ++nextInstallId;
+}
+
+AddonInstallWrapper.prototype = {
+ get __AddonInstallInternal__() {
+ return AppConstants.DEBUG ? installFor(this) : undefined;
+ },
+
+ get error() {
+ return installFor(this).error;
+ },
+
+ set error(err) {
+ installFor(this).error = err;
+ },
+
+ get type() {
+ return installFor(this).type;
+ },
+
+ get iconURL() {
+ return installFor(this).icons[32];
+ },
+
+ get existingAddon() {
+ let install = installFor(this);
+ return install.existingAddon ? install.existingAddon.wrapper : null;
+ },
+
+ get addon() {
+ let install = installFor(this);
+ return install.addon ? install.addon.wrapper : null;
+ },
+
+ get sourceURI() {
+ return installFor(this).sourceURI;
+ },
+
+ set promptHandler(handler) {
+ installFor(this).promptHandler = handler;
+ },
+
+ get promptHandler() {
+ return installFor(this).promptHandler;
+ },
+
+ get installTelemetryInfo() {
+ return installFor(this).installTelemetryInfo;
+ },
+
+ get isUserRequestedUpdate() {
+ return Boolean(installFor(this).isUserRequestedUpdate);
+ },
+
+ get downloadStartedAt() {
+ return installFor(this).downloadStartedAt;
+ },
+
+ get hashedAddonId() {
+ const addon = this.addon;
+
+ if (!addon) {
+ return null;
+ }
+
+ return computeSha256HashAsString(addon.id);
+ },
+
+ install() {
+ return installFor(this).install();
+ },
+
+ postpone(returnFn, requiresRestart) {
+ return installFor(this).postpone(returnFn, requiresRestart);
+ },
+
+ cancel() {
+ installFor(this).cancel();
+ },
+
+ continuePostponedInstall() {
+ return installFor(this).continuePostponedInstall();
+ },
+
+ addListener(listener) {
+ installFor(this).addListener(listener);
+ },
+
+ removeListener(listener) {
+ installFor(this).removeListener(listener);
+ },
+};
+
+[
+ "name",
+ "version",
+ "icons",
+ "releaseNotesURI",
+ "file",
+ "state",
+ "progress",
+ "maxProgress",
+].forEach(function (aProp) {
+ Object.defineProperty(AddonInstallWrapper.prototype, aProp, {
+ get() {
+ return installFor(this)[aProp];
+ },
+ enumerable: true,
+ });
+});
+
+/**
+ * Creates a new update checker.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to check for updates
+ * @param {UpdateListener} aListener
+ * An UpdateListener to notify of updates
+ * @param {integer} aReason
+ * The reason for the update check
+ * @param {string} [aAppVersion]
+ * An optional application version to check for updates for
+ * @param {string} [aPlatformVersion]
+ * An optional platform version to check for updates for
+ * @throws if the aListener or aReason arguments are not valid
+ */
+var AddonUpdateChecker;
+
+export var UpdateChecker = function (
+ aAddon,
+ aListener,
+ aReason,
+ aAppVersion,
+ aPlatformVersion
+) {
+ if (!aListener || !aReason) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ ({ AddonUpdateChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonUpdateChecker.sys.mjs"
+ ));
+
+ this.addon = aAddon;
+ aAddon._updateCheck = this;
+ XPIInstall.doing(this);
+ this.listener = aListener;
+ this.appVersion = aAppVersion;
+ this.platformVersion = aPlatformVersion;
+ this.syncCompatibility =
+ aReason == AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED;
+ this.isUserRequested = aReason == AddonManager.UPDATE_WHEN_USER_REQUESTED;
+
+ let updateURL = aAddon.updateURL;
+ if (!updateURL) {
+ if (
+ aReason == AddonManager.UPDATE_WHEN_PERIODIC_UPDATE &&
+ Services.prefs.getPrefType(PREF_EM_UPDATE_BACKGROUND_URL) ==
+ Services.prefs.PREF_STRING
+ ) {
+ updateURL = Services.prefs.getCharPref(PREF_EM_UPDATE_BACKGROUND_URL);
+ } else {
+ updateURL = Services.prefs.getCharPref(PREF_EM_UPDATE_URL);
+ }
+ }
+
+ const UPDATE_TYPE_COMPATIBILITY = 32;
+ const UPDATE_TYPE_NEWVERSION = 64;
+
+ aReason |= UPDATE_TYPE_COMPATIBILITY;
+ if ("onUpdateAvailable" in this.listener) {
+ aReason |= UPDATE_TYPE_NEWVERSION;
+ }
+
+ let url = escapeAddonURI(aAddon, updateURL, aReason, aAppVersion);
+ this._parser = AddonUpdateChecker.checkForUpdates(aAddon.id, url, this);
+};
+
+UpdateChecker.prototype = {
+ addon: null,
+ listener: null,
+ appVersion: null,
+ platformVersion: null,
+ syncCompatibility: null,
+
+ /**
+ * Calls a method on the listener passing any number of arguments and
+ * consuming any exceptions.
+ *
+ * @param {string} aMethod
+ * The method to call on the listener
+ * @param {any[]} aArgs
+ * Additional arguments to pass to the listener.
+ */
+ callListener(aMethod, ...aArgs) {
+ if (!(aMethod in this.listener)) {
+ return;
+ }
+
+ try {
+ this.listener[aMethod].apply(this.listener, aArgs);
+ } catch (e) {
+ logger.warn("Exception calling UpdateListener method " + aMethod, e);
+ }
+ },
+
+ /**
+ * Called when AddonUpdateChecker completes the update check
+ *
+ * @param {object[]} aUpdates
+ * The list of update details for the add-on
+ */
+ async onUpdateCheckComplete(aUpdates) {
+ XPIInstall.done(this.addon._updateCheck);
+ this.addon._updateCheck = null;
+ let AUC = AddonUpdateChecker;
+ let ignoreMaxVersion = false;
+ // Ignore strict compatibility for dictionaries by default.
+ let ignoreStrictCompat = this.addon.type == "dictionary";
+ if (!AddonManager.checkCompatibility) {
+ ignoreMaxVersion = true;
+ ignoreStrictCompat = true;
+ } else if (
+ !AddonManager.strictCompatibility &&
+ !this.addon.strictCompatibility
+ ) {
+ ignoreMaxVersion = true;
+ }
+
+ // Always apply any compatibility update for the current version
+ let compatUpdate = AUC.getCompatibilityUpdate(
+ aUpdates,
+ this.addon.version,
+ this.syncCompatibility,
+ null,
+ null,
+ ignoreMaxVersion,
+ ignoreStrictCompat
+ );
+ // Apply the compatibility update to the database
+ if (compatUpdate) {
+ this.addon.applyCompatibilityUpdate(compatUpdate, this.syncCompatibility);
+ }
+
+ // If the request is for an application or platform version that is
+ // different to the current application or platform version then look for a
+ // compatibility update for those versions.
+ if (
+ (this.appVersion &&
+ Services.vc.compare(this.appVersion, Services.appinfo.version) != 0) ||
+ (this.platformVersion &&
+ Services.vc.compare(
+ this.platformVersion,
+ Services.appinfo.platformVersion
+ ) != 0)
+ ) {
+ compatUpdate = AUC.getCompatibilityUpdate(
+ aUpdates,
+ this.addon.version,
+ false,
+ this.appVersion,
+ this.platformVersion,
+ ignoreMaxVersion,
+ ignoreStrictCompat
+ );
+ }
+
+ if (compatUpdate) {
+ this.callListener("onCompatibilityUpdateAvailable", this.addon.wrapper);
+ } else {
+ this.callListener("onNoCompatibilityUpdateAvailable", this.addon.wrapper);
+ }
+
+ function sendUpdateAvailableMessages(aSelf, aInstall) {
+ if (aInstall) {
+ aSelf.callListener(
+ "onUpdateAvailable",
+ aSelf.addon.wrapper,
+ aInstall.wrapper
+ );
+ } else {
+ aSelf.callListener("onNoUpdateAvailable", aSelf.addon.wrapper);
+ }
+ aSelf.callListener(
+ "onUpdateFinished",
+ aSelf.addon.wrapper,
+ AddonManager.UPDATE_STATUS_NO_ERROR
+ );
+ }
+
+ let update = await AUC.getNewestCompatibleUpdate(
+ aUpdates,
+ this.addon,
+ this.appVersion,
+ this.platformVersion,
+ ignoreMaxVersion,
+ ignoreStrictCompat
+ );
+
+ if (update && !this.addon.location.locked) {
+ for (let currentInstall of XPIInstall.installs) {
+ // Skip installs that don't match the available update
+ if (
+ currentInstall.existingAddon != this.addon ||
+ currentInstall.version != update.version
+ ) {
+ continue;
+ }
+
+ // If the existing install has not yet started downloading then send an
+ // available update notification. If it is already downloading then
+ // don't send any available update notification
+ if (currentInstall.state == AddonManager.STATE_AVAILABLE) {
+ logger.debug("Found an existing AddonInstall for " + this.addon.id);
+ sendUpdateAvailableMessages(this, currentInstall);
+ } else {
+ sendUpdateAvailableMessages(this, null);
+ }
+ return;
+ }
+
+ createUpdate(
+ aInstall => {
+ sendUpdateAvailableMessages(this, aInstall);
+ },
+ this.addon,
+ update,
+ this.isUserRequested
+ );
+ } else {
+ sendUpdateAvailableMessages(this, null);
+ }
+ },
+
+ /**
+ * Called when AddonUpdateChecker fails the update check
+ *
+ * @param {any} aError
+ * An error status
+ */
+ onUpdateCheckError(aError) {
+ XPIInstall.done(this.addon._updateCheck);
+ this.addon._updateCheck = null;
+ this.callListener("onNoCompatibilityUpdateAvailable", this.addon.wrapper);
+ this.callListener("onNoUpdateAvailable", this.addon.wrapper);
+ this.callListener("onUpdateFinished", this.addon.wrapper, aError);
+ },
+
+ /**
+ * Called to cancel an in-progress update check
+ */
+ cancel() {
+ let parser = this._parser;
+ if (parser) {
+ this._parser = null;
+ // This will call back to onUpdateCheckError with a CANCELLED error
+ parser.cancel();
+ }
+ },
+};
+
+/**
+ * Creates a new AddonInstall to install an add-on from a local file.
+ *
+ * @param {nsIFile} file
+ * The file to install
+ * @param {XPIStateLocation} location
+ * The location to install to
+ * @param {Object?} [telemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @returns {Promise<AddonInstall>}
+ * A Promise that resolves with the new install object.
+ */
+function createLocalInstall(file, location, telemetryInfo) {
+ if (!location) {
+ location = XPIExports.XPIInternal.XPIStates.getLocation(
+ XPIExports.XPIInternal.KEY_APP_PROFILE
+ );
+ }
+ let url = Services.io.newFileURI(file);
+
+ try {
+ let install = new LocalAddonInstall(location, url, { telemetryInfo });
+ return install.init().then(() => install);
+ } catch (e) {
+ logger.error("Error creating install", e);
+ return Promise.resolve(null);
+ }
+}
+
+/**
+ * Uninstall an addon from a location. This allows removing non-visible
+ * addons, such as system addon upgrades, when a higher precedence addon
+ * is installed.
+ *
+ * @param {string} addonID
+ * ID of the addon being removed.
+ * @param {XPIStateLocation} location
+ * The location to remove the addon from.
+ */
+async function uninstallAddonFromLocation(addonID, location) {
+ let existing = await XPIExports.XPIDatabase.getAddonInLocation(
+ addonID,
+ location.name
+ );
+ if (!existing) {
+ return;
+ }
+ if (existing.active) {
+ let a = await AddonManager.getAddonByID(addonID);
+ if (a) {
+ await a.uninstall();
+ }
+ } else {
+ XPIExports.XPIDatabase.removeAddonMetadata(existing);
+ location.removeAddon(addonID);
+ XPIExports.XPIInternal.XPIStates.save();
+ AddonManagerPrivate.callAddonListeners("onUninstalled", existing);
+ }
+}
+
+class DirectoryInstaller {
+ constructor(location) {
+ this.location = location;
+
+ this._stagingDirLock = 0;
+ this._stagingDirPromise = null;
+ }
+
+ get name() {
+ return this.location.name;
+ }
+
+ get dir() {
+ return this.location.dir;
+ }
+ set dir(val) {
+ this.location.dir = val;
+ this.location.path = val.path;
+ }
+
+ /**
+ * Gets the staging directory to put add-ons that are pending install and
+ * uninstall into.
+ *
+ * @returns {nsIFile}
+ */
+ getStagingDir() {
+ return getFile(XPIExports.XPIInternal.DIR_STAGE, this.dir);
+ }
+
+ requestStagingDir() {
+ this._stagingDirLock++;
+
+ if (this._stagingDirPromise) {
+ return this._stagingDirPromise;
+ }
+
+ let stagepath = PathUtils.join(
+ this.dir.path,
+ XPIExports.XPIInternal.DIR_STAGE
+ );
+ return (this._stagingDirPromise = IOUtils.makeDirectory(stagepath, {
+ createAncestors: true,
+ ignoreExisting: true,
+ }).catch(e => {
+ logger.error("Failed to create staging directory", e);
+ throw e;
+ }));
+ }
+
+ releaseStagingDir() {
+ this._stagingDirLock--;
+
+ if (this._stagingDirLock == 0) {
+ this._stagingDirPromise = null;
+ this.cleanStagingDir();
+ }
+
+ return Promise.resolve();
+ }
+
+ /**
+ * Removes the specified files or directories in the staging directory and
+ * then if the staging directory is empty attempts to remove it.
+ *
+ * @param {string[]} [aLeafNames = []]
+ * An array of file or directory to remove from the directory, the
+ * array may be empty
+ */
+ cleanStagingDir(aLeafNames = []) {
+ let dir = this.getStagingDir();
+
+ // SystemAddonInstaller getStatingDir may return null if there isn't
+ // any addon set directory returned by SystemAddonInstaller._loadAddonSet.
+ if (!dir) {
+ return;
+ }
+
+ for (let name of aLeafNames) {
+ let file = getFile(name, dir);
+ recursiveRemove(file);
+ }
+
+ if (this._stagingDirLock > 0) {
+ return;
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ for (let file of XPIExports.XPIInternal.iterDirectory(dir)) {
+ return;
+ }
+
+ try {
+ setFilePermissions(dir, lazy.FileUtils.PERMS_DIRECTORY);
+ dir.remove(false);
+ } catch (e) {
+ logger.warn("Failed to remove staging dir", e);
+ // Failing to remove the staging directory is ignorable
+ }
+ }
+
+ /**
+ * Returns a directory that is normally on the same filesystem as the rest of
+ * the install location and can be used for temporarily storing files during
+ * safe move operations. Calling this method will delete the existing trash
+ * directory and its contents.
+ *
+ * @returns {nsIFile}
+ */
+ getTrashDir() {
+ let trashDir = getFile(XPIExports.XPIInternal.DIR_TRASH, this.dir);
+ let trashDirExists = trashDir.exists();
+ try {
+ if (trashDirExists) {
+ recursiveRemove(trashDir);
+ }
+ trashDirExists = false;
+ } catch (e) {
+ logger.warn("Failed to remove trash directory", e);
+ }
+ if (!trashDirExists) {
+ trashDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ }
+
+ return trashDir;
+ }
+
+ /**
+ * Installs an add-on into the install location.
+ *
+ * @param {Object} options
+ * Installation options.
+ * @param {string} options.id
+ * The ID of the add-on to install
+ * @param {nsIFile} options.source
+ * The source nsIFile to install from
+ * @param {string} options.action
+ * What to we do with the given source file:
+ * "move"
+ * Default action, the source files will be moved to the new
+ * location,
+ * "copy"
+ * The source files will be copied,
+ * "proxy"
+ * A "proxy file" is going to refer to the source file path
+ * @returns {nsIFile}
+ * An nsIFile indicating where the add-on was installed to
+ */
+ installAddon({ id, source, action = "move" }) {
+ let trashDir = this.getTrashDir();
+
+ let transaction = new SafeInstallOperation();
+
+ let moveOldAddon = aId => {
+ let file = getFile(aId, this.dir);
+ if (file.exists()) {
+ transaction.moveUnder(file, trashDir);
+ }
+
+ file = getFile(`${aId}.xpi`, this.dir);
+ if (file.exists()) {
+ flushJarCache(file);
+ transaction.moveUnder(file, trashDir);
+ }
+ };
+
+ // If any of these operations fails the finally block will clean up the
+ // temporary directory
+ try {
+ moveOldAddon(id);
+ if (action == "copy") {
+ transaction.copy(source, this.dir);
+ } else if (action == "move") {
+ flushJarCache(source);
+ transaction.moveUnder(source, this.dir);
+ }
+ // Do nothing for the proxy file as we sideload an addon permanently
+ } finally {
+ // It isn't ideal if this cleanup fails but it isn't worth rolling back
+ // the install because of it.
+ try {
+ recursiveRemove(trashDir);
+ } catch (e) {
+ logger.warn(
+ `Failed to remove trash directory when installing ${id}`,
+ e
+ );
+ }
+ }
+
+ let newFile = this.dir.clone();
+
+ if (action == "proxy") {
+ // When permanently installing sideloaded addon, we just put a proxy file
+ // referring to the addon sources
+ newFile.append(id);
+
+ writeStringToFile(newFile, source.path);
+ } else {
+ newFile.append(source.leafName);
+ }
+
+ try {
+ newFile.lastModifiedTime = Date.now();
+ } catch (e) {
+ logger.warn(`failed to set lastModifiedTime on ${newFile.path}`, e);
+ }
+
+ return newFile;
+ }
+
+ /**
+ * Uninstalls an add-on from this location.
+ *
+ * @param {string} aId
+ * The ID of the add-on to uninstall
+ * @throws if the ID does not match any of the add-ons installed
+ */
+ uninstallAddon(aId) {
+ let file = getFile(aId, this.dir);
+ if (!file.exists()) {
+ file.leafName += ".xpi";
+ }
+
+ if (!file.exists()) {
+ logger.warn(
+ `Attempted to remove ${aId} from ${this.name} but it was already gone`
+ );
+ this.location.delete(aId);
+ return;
+ }
+
+ if (file.leafName != aId) {
+ logger.debug(
+ `uninstallAddon: flushing jar cache ${file.path} for addon ${aId}`
+ );
+ flushJarCache(file);
+ }
+
+ // In case this is a foreignInstall we do not want to remove the file if
+ // the location is locked.
+ if (!this.location.locked) {
+ let trashDir = this.getTrashDir();
+ let transaction = new SafeInstallOperation();
+
+ try {
+ transaction.moveUnder(file, trashDir);
+ } finally {
+ // It isn't ideal if this cleanup fails, but it is probably better than
+ // rolling back the uninstall at this point
+ try {
+ recursiveRemove(trashDir);
+ } catch (e) {
+ logger.warn(
+ `Failed to remove trash directory when uninstalling ${aId}`,
+ e
+ );
+ }
+ }
+ }
+
+ this.location.removeAddon(aId);
+ }
+}
+
+class SystemAddonInstaller extends DirectoryInstaller {
+ constructor(location) {
+ super(location);
+
+ this._baseDir = location._baseDir;
+ this._nextDir = null;
+ }
+
+ get _addonSet() {
+ return this.location._addonSet;
+ }
+ set _addonSet(val) {
+ this.location._addonSet = val;
+ }
+
+ /**
+ * Saves the current set of system add-ons
+ *
+ * @param {Object} aAddonSet - object containing schema, directory and set
+ * of system add-on IDs and versions.
+ */
+ static _saveAddonSet(aAddonSet) {
+ Services.prefs.setStringPref(
+ XPIExports.XPIInternal.PREF_SYSTEM_ADDON_SET,
+ JSON.stringify(aAddonSet)
+ );
+ }
+
+ static _loadAddonSet() {
+ return XPIExports.XPIInternal.SystemAddonLocation._loadAddonSet();
+ }
+
+ /**
+ * Gets the staging directory to put add-ons that are pending install and
+ * uninstall into.
+ *
+ * @returns {nsIFile}
+ * Staging directory for system add-on upgrades.
+ */
+ getStagingDir() {
+ this._addonSet = SystemAddonInstaller._loadAddonSet();
+ let dir = null;
+ if (this._addonSet.directory) {
+ this.dir = getFile(this._addonSet.directory, this._baseDir);
+ dir = getFile(XPIExports.XPIInternal.DIR_STAGE, this.dir);
+ } else {
+ logger.info("SystemAddonInstaller directory is missing");
+ }
+
+ return dir;
+ }
+
+ requestStagingDir() {
+ this._addonSet = SystemAddonInstaller._loadAddonSet();
+ if (this._addonSet.directory) {
+ this.dir = getFile(this._addonSet.directory, this._baseDir);
+ }
+ return super.requestStagingDir();
+ }
+
+ isValidAddon(aAddon) {
+ if (aAddon.appDisabled) {
+ logger.warn(
+ `System add-on ${aAddon.id} isn't compatible with the application.`
+ );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Tests whether the loaded add-on information matches what is expected.
+ *
+ * @param {Map<string, AddonInternal>} aAddons
+ * The set of add-ons to check.
+ * @returns {boolean}
+ * True if all of the given add-ons are valid.
+ */
+ isValid(aAddons) {
+ for (let id of Object.keys(this._addonSet.addons)) {
+ if (!aAddons.has(id)) {
+ logger.warn(
+ `Expected add-on ${id} is missing from the system add-on location.`
+ );
+ return false;
+ }
+
+ let addon = aAddons.get(id);
+ if (addon.version != this._addonSet.addons[id].version) {
+ logger.warn(
+ `Expected system add-on ${id} to be version ${this._addonSet.addons[id].version} but was ${addon.version}.`
+ );
+ return false;
+ }
+
+ if (!this.isValidAddon(addon)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Resets the add-on set so on the next startup the default set will be used.
+ */
+ async resetAddonSet() {
+ logger.info("Removing all system add-on upgrades.");
+
+ // remove everything from the pref first, if uninstall
+ // fails then at least they will not be re-activated on
+ // next restart.
+ let addonSet = this._addonSet;
+ this._addonSet = { schema: 1, addons: {} };
+ SystemAddonInstaller._saveAddonSet(this._addonSet);
+
+ // If this is running at app startup, the pref being cleared
+ // will cause later stages of startup to notice that the
+ // old updates are now gone.
+ //
+ // Updates will only be explicitly uninstalled if they are
+ // removed restartlessly, for instance if they are no longer
+ // part of the latest update set.
+ if (addonSet) {
+ for (let addonID of Object.keys(addonSet.addons)) {
+ await uninstallAddonFromLocation(addonID, this.location);
+ }
+ }
+ }
+
+ /**
+ * Removes any directories not currently in use or pending use after a
+ * restart. Any errors that happen here don't really matter as we'll attempt
+ * to cleanup again next time.
+ */
+ async cleanDirectories() {
+ try {
+ let children = await IOUtils.getChildren(this._baseDir.path, {
+ ignoreAbsent: true,
+ });
+ for (let path of children) {
+ // Skip the directory currently in use
+ if (this.dir && this.dir.path == path) {
+ continue;
+ }
+
+ // Skip the next directory
+ if (this._nextDir && this._nextDir.path == path) {
+ continue;
+ }
+
+ await IOUtils.remove(path, {
+ ignoreAbsent: true,
+ recursive: true,
+ });
+ }
+ } catch (e) {
+ logger.error("Failed to clean updated system add-ons directories.", e);
+ }
+ }
+
+ /**
+ * Installs a new set of system add-ons into the location and updates the
+ * add-on set in prefs.
+ *
+ * @param {Array} aAddons - An array of addons to install.
+ */
+ async installAddonSet(aAddons) {
+ // Make sure the base dir exists
+ await IOUtils.makeDirectory(this._baseDir.path, { ignoreExisting: true });
+
+ let addonSet = SystemAddonInstaller._loadAddonSet();
+
+ // Remove any add-ons that are no longer part of the set.
+ const ids = aAddons.map(a => a.id);
+ for (let addonID of Object.keys(addonSet.addons)) {
+ if (!ids.includes(addonID)) {
+ await uninstallAddonFromLocation(addonID, this.location);
+ }
+ }
+
+ let newDir = this._baseDir.clone();
+ newDir.append("blank");
+
+ while (true) {
+ newDir.leafName = Services.uuid.generateUUID().toString();
+ try {
+ await IOUtils.makeDirectory(newDir.path, { ignoreExisting: false });
+ break;
+ } catch (e) {
+ logger.debug(
+ "Could not create new system add-on updates dir, retrying",
+ e
+ );
+ }
+ }
+
+ // Record the new upgrade directory.
+ let state = { schema: 1, directory: newDir.leafName, addons: {} };
+ SystemAddonInstaller._saveAddonSet(state);
+
+ this._nextDir = newDir;
+
+ let installs = [];
+ for (let addon of aAddons) {
+ let install = await createLocalInstall(
+ addon._sourceBundle,
+ this.location,
+ // Make sure that system addons being installed for the first time through
+ // Balrog have telemetryInfo associated with them (on the contrary the ones
+ // updated through Balrog but part of the build will already have the same
+ // `source`, but we expect no `method` to be set for them).
+ {
+ source: "system-addon",
+ method: "product-updates",
+ }
+ );
+ installs.push(install);
+ }
+
+ async function installAddon(install) {
+ // Make the new install own its temporary file.
+ install.ownsTempFile = true;
+ install.install();
+ }
+
+ async function postponeAddon(install) {
+ install.ownsTempFile = true;
+ let resumeFn;
+ if (AddonManagerPrivate.hasUpgradeListener(install.addon.id)) {
+ logger.info(
+ `system add-on ${install.addon.id} has an upgrade listener, postponing upgrade set until restart`
+ );
+ resumeFn = () => {
+ logger.info(
+ `${install.addon.id} has resumed a previously postponed addon set`
+ );
+ install.location.installer.resumeAddonSet(installs);
+ };
+ }
+ await install.postpone(resumeFn);
+ }
+
+ let previousState;
+
+ try {
+ // All add-ons in position, create the new state and store it in prefs
+ state = { schema: 1, directory: newDir.leafName, addons: {} };
+ for (let addon of aAddons) {
+ state.addons[addon.id] = {
+ version: addon.version,
+ };
+ }
+
+ previousState = SystemAddonInstaller._loadAddonSet();
+ SystemAddonInstaller._saveAddonSet(state);
+
+ let blockers = aAddons.filter(addon =>
+ AddonManagerPrivate.hasUpgradeListener(addon.id)
+ );
+
+ if (blockers.length) {
+ await waitForAllPromises(installs.map(postponeAddon));
+ } else {
+ await waitForAllPromises(installs.map(installAddon));
+ }
+ } catch (e) {
+ // Roll back to previous upgrade set (if present) on restart.
+ if (previousState) {
+ SystemAddonInstaller._saveAddonSet(previousState);
+ }
+ // Otherwise, roll back to built-in set on restart.
+ // TODO try to do these restartlessly
+ await this.resetAddonSet();
+
+ try {
+ await IOUtils.remove(newDir.path, { recursive: true });
+ } catch (e) {
+ logger.warn(
+ `Failed to remove failed system add-on directory ${newDir.path}.`,
+ e
+ );
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Resumes upgrade of a previously-delayed add-on set.
+ *
+ * @param {AddonInstall[]} installs
+ * The set of installs to resume.
+ */
+ async resumeAddonSet(installs) {
+ async function resumeAddon(install) {
+ install.state = AddonManager.STATE_DOWNLOADED;
+ install.location.installer.releaseStagingDir();
+ install.install();
+ }
+
+ let blockers = installs.filter(install =>
+ AddonManagerPrivate.hasUpgradeListener(install.addon.id)
+ );
+
+ if (blockers.length > 1) {
+ logger.warn(
+ "Attempted to resume system add-on install but upgrade blockers are still present"
+ );
+ } else {
+ await waitForAllPromises(installs.map(resumeAddon));
+ }
+ }
+
+ /**
+ * Returns a directory that is normally on the same filesystem as the rest of
+ * the install location and can be used for temporarily storing files during
+ * safe move operations. Calling this method will delete the existing trash
+ * directory and its contents.
+ *
+ * @returns {nsIFile}
+ */
+ getTrashDir() {
+ let trashDir = getFile(XPIExports.XPIInternal.DIR_TRASH, this.dir);
+ let trashDirExists = trashDir.exists();
+ try {
+ if (trashDirExists) {
+ recursiveRemove(trashDir);
+ }
+ trashDirExists = false;
+ } catch (e) {
+ logger.warn("Failed to remove trash directory", e);
+ }
+ if (!trashDirExists) {
+ trashDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ }
+
+ return trashDir;
+ }
+
+ /**
+ * Installs an add-on into the install location.
+ *
+ * @param {string} id
+ * The ID of the add-on to install
+ * @param {nsIFile} source
+ * The source nsIFile to install from
+ * @returns {nsIFile}
+ * An nsIFile indicating where the add-on was installed to
+ */
+ installAddon({ id, source }) {
+ let trashDir = this.getTrashDir();
+ let transaction = new SafeInstallOperation();
+
+ // If any of these operations fails the finally block will clean up the
+ // temporary directory
+ try {
+ flushJarCache(source);
+
+ transaction.moveUnder(source, this.dir);
+ } finally {
+ // It isn't ideal if this cleanup fails but it isn't worth rolling back
+ // the install because of it.
+ try {
+ recursiveRemove(trashDir);
+ } catch (e) {
+ logger.warn(
+ `Failed to remove trash directory when installing ${id}`,
+ e
+ );
+ }
+ }
+
+ let newFile = getFile(source.leafName, this.dir);
+
+ try {
+ newFile.lastModifiedTime = Date.now();
+ } catch (e) {
+ logger.warn("failed to set lastModifiedTime on " + newFile.path, e);
+ }
+
+ return newFile;
+ }
+
+ // old system add-on upgrade dirs get automatically removed
+ uninstallAddon(aAddon) {}
+}
+
+var AppUpdate = {
+ findAddonUpdates(addon, reason, appVersion, platformVersion) {
+ return new Promise((resolve, reject) => {
+ let update = null;
+ addon.findUpdates(
+ {
+ onUpdateAvailable(addon2, install) {
+ update = install;
+ },
+
+ onUpdateFinished(addon2, error) {
+ if (error == AddonManager.UPDATE_STATUS_NO_ERROR) {
+ resolve(update);
+ } else {
+ reject(error);
+ }
+ },
+ },
+ reason,
+ appVersion,
+ platformVersion || appVersion
+ );
+ });
+ },
+
+ stageInstall(installer) {
+ return new Promise((resolve, reject) => {
+ let listener = {
+ onDownloadEnded: install => {
+ install.postpone();
+ },
+ onInstallFailed: install => {
+ install.removeListener(listener);
+ reject();
+ },
+ onInstallEnded: install => {
+ // We shouldn't end up here, but if we do, resolve
+ // since we've installed.
+ install.removeListener(listener);
+ resolve();
+ },
+ onInstallPostponed: install => {
+ // At this point the addon is staged for restart.
+ install.removeListener(listener);
+ resolve();
+ },
+ };
+
+ installer.addListener(listener);
+ installer.install();
+ });
+ },
+
+ async stageLangpackUpdates(nextVersion, nextPlatformVersion) {
+ let updates = [];
+ let addons = await AddonManager.getAddonsByTypes(["locale"]);
+ for (let addon of addons) {
+ updates.push(
+ this.findAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_NEW_APP_DETECTED,
+ nextVersion,
+ nextPlatformVersion
+ )
+ .then(update => update && this.stageInstall(update))
+ .catch(e => {
+ logger.debug(`addon.findUpdate error: ${e}`);
+ })
+ );
+ }
+ return Promise.all(updates);
+ },
+};
+
+export var XPIInstall = {
+ // An array of currently active AddonInstalls
+ installs: new Set(),
+
+ createLocalInstall,
+ flushJarCache,
+ newVersionReason,
+ recursiveRemove,
+ syncLoadManifest,
+ loadManifestFromFile,
+ uninstallAddonFromLocation,
+
+ stageLangpacksForAppUpdate(nextVersion, nextPlatformVersion) {
+ return AppUpdate.stageLangpackUpdates(nextVersion, nextPlatformVersion);
+ },
+
+ // Keep track of in-progress operations that support cancel()
+ _inProgress: [],
+
+ doing(aCancellable) {
+ this._inProgress.push(aCancellable);
+ },
+
+ done(aCancellable) {
+ let i = this._inProgress.indexOf(aCancellable);
+ if (i != -1) {
+ this._inProgress.splice(i, 1);
+ return true;
+ }
+ return false;
+ },
+
+ cancelAll() {
+ // Cancelling one may alter _inProgress, so don't use a simple iterator
+ while (this._inProgress.length) {
+ let c = this._inProgress.shift();
+ try {
+ c.cancel();
+ } catch (e) {
+ logger.warn("Cancel failed", e);
+ }
+ }
+ },
+
+ /**
+ * @param {string} id
+ * The expected ID of the add-on.
+ * @param {nsIFile} file
+ * The XPI file to install the add-on from.
+ * @param {XPIStateLocation} location
+ * The install location to install the add-on to.
+ * @param {string?} [oldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @returns {AddonInternal}
+ * The installed Addon object, upon success.
+ */
+ async installDistributionAddon(id, file, location, oldAppVersion) {
+ let addon = await loadManifestFromFile(file, location);
+ addon.installTelemetryInfo = { source: "distribution" };
+
+ if (addon.id != id) {
+ throw new Error(
+ `File file ${file.path} contains an add-on with an incorrect ID`
+ );
+ }
+
+ let state = location.get(id);
+
+ if (state) {
+ try {
+ let existingAddon = await loadManifestFromFile(state.file, location);
+
+ if (Services.vc.compare(addon.version, existingAddon.version) <= 0) {
+ return null;
+ }
+ } catch (e) {
+ // Bad add-on in the profile so just proceed and install over the top
+ logger.warn(
+ "Profile contains an add-on with a bad or missing install " +
+ `manifest at ${state.path}, overwriting`,
+ e
+ );
+ }
+ } else if (
+ addon.type === "locale" &&
+ oldAppVersion &&
+ Services.vc.compare(oldAppVersion, "67") < 0
+ ) {
+ /* Distribution language packs didn't get installed due to the signing
+ issues so we need to force them to be reinstalled. */
+ Services.prefs.clearUserPref(
+ XPIExports.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id
+ );
+ } else if (
+ Services.prefs.getBoolPref(
+ XPIExports.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id,
+ false
+ )
+ ) {
+ return null;
+ }
+
+ // Install the add-on
+ addon.sourceBundle = location.installer.installAddon({
+ id,
+ source: file,
+ action: "copy",
+ });
+
+ XPIExports.XPIInternal.XPIStates.addAddon(addon);
+ logger.debug(`Installed distribution add-on ${id}`);
+
+ Services.prefs.setBoolPref(
+ XPIExports.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id,
+ true
+ );
+
+ return addon;
+ },
+
+ /**
+ * Completes the install of an add-on which was staged during the last
+ * session.
+ *
+ * @param {string} id
+ * The expected ID of the add-on.
+ * @param {object} metadata
+ * The parsed metadata for the staged install.
+ * @param {XPIStateLocation} location
+ * The install location to install the add-on to.
+ * @returns {AddonInternal}
+ * The installed Addon object, upon success.
+ */
+ async installStagedAddon(id, metadata, location) {
+ let source = getFile(`${id}.xpi`, location.installer.getStagingDir());
+
+ // Check that the directory's name is a valid ID.
+ if (!gIDTest.test(id) || !source.exists() || !source.isFile()) {
+ throw new Error(`Ignoring invalid staging directory entry: ${id}`);
+ }
+
+ let addon = await loadManifestFromFile(source, location);
+
+ if (
+ XPIExports.XPIDatabase.mustSign(addon.type) &&
+ addon.signedState <= AddonManager.SIGNEDSTATE_MISSING
+ ) {
+ throw new Error(
+ `Refusing to install staged add-on ${id} with signed state ${addon.signedState}`
+ );
+ }
+
+ // Import saved metadata before checking for compatibility.
+ addon.importMetadata(metadata);
+
+ // Ensure a staged addon is compatible with the current running version of
+ // Firefox. If a prior version of the addon is installed, it will remain.
+ if (!addon.isCompatible) {
+ throw new Error(
+ `Add-on ${addon.id} is not compatible with application version.`
+ );
+ }
+
+ logger.debug(`Processing install of ${id} in ${location.name}`);
+ let existingAddon = XPIExports.XPIInternal.XPIStates.findAddon(id);
+ // This part of the startup file changes is called from
+ // processPendingFileChanges, no addons are started yet.
+ // Here we handle copying the xpi into its proper place, later
+ // processFileChanges will call update.
+ try {
+ addon.sourceBundle = location.installer.installAddon({
+ id,
+ source,
+ });
+ XPIExports.XPIInternal.XPIStates.addAddon(addon);
+ } catch (e) {
+ if (existingAddon) {
+ // Re-install the old add-on
+ XPIExports.XPIInternal.get(existingAddon).install();
+ }
+ throw e;
+ }
+
+ return addon;
+ },
+
+ async updateSystemAddons() {
+ let systemAddonLocation = XPIExports.XPIInternal.XPIStates.getLocation(
+ XPIExports.XPIInternal.KEY_APP_SYSTEM_ADDONS
+ );
+ if (!systemAddonLocation) {
+ return;
+ }
+
+ let installer = systemAddonLocation.installer;
+
+ // Don't do anything in safe mode
+ if (Services.appinfo.inSafeMode) {
+ return;
+ }
+
+ // Download the list of system add-ons
+ let url = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_UPDATE_URL, null);
+ if (!url) {
+ await installer.cleanDirectories();
+ return;
+ }
+
+ url = await lazy.UpdateUtils.formatUpdateURL(url);
+
+ logger.info(`Starting system add-on update check from ${url}.`);
+ let res = await lazy.ProductAddonChecker.getProductAddonList(
+ url,
+ true
+ ).catch(e => logger.error(`System addon update list error ${e}`));
+
+ // If there was no list then do nothing.
+ if (!res || !res.addons) {
+ logger.info("No system add-ons list was returned.");
+ await installer.cleanDirectories();
+ return;
+ }
+
+ let addonList = new Map(
+ res.addons.map(spec => [spec.id, { spec, path: null, addon: null }])
+ );
+
+ let setMatches = (wanted, existing) => {
+ if (wanted.size != existing.size) {
+ return false;
+ }
+
+ for (let [id, addon] of existing) {
+ let wantedInfo = wanted.get(id);
+
+ if (!wantedInfo) {
+ return false;
+ }
+ if (wantedInfo.spec.version != addon.version) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ // If this matches the current set in the profile location then do nothing.
+ let updatedAddons = addonMap(
+ await XPIExports.XPIDatabase.getAddonsInLocation(
+ XPIExports.XPIInternal.KEY_APP_SYSTEM_ADDONS
+ )
+ );
+ if (setMatches(addonList, updatedAddons)) {
+ logger.info("Retaining existing updated system add-ons.");
+ await installer.cleanDirectories();
+ return;
+ }
+
+ // If this matches the current set in the default location then reset the
+ // updated set.
+ let defaultAddons = addonMap(
+ await XPIExports.XPIDatabase.getAddonsInLocation(
+ XPIExports.XPIInternal.KEY_APP_SYSTEM_DEFAULTS
+ )
+ );
+ if (setMatches(addonList, defaultAddons)) {
+ logger.info("Resetting system add-ons.");
+ await installer.resetAddonSet();
+ await installer.cleanDirectories();
+ return;
+ }
+
+ // Download all the add-ons
+ async function downloadAddon(item) {
+ try {
+ let sourceAddon = updatedAddons.get(item.spec.id);
+ if (sourceAddon && sourceAddon.version == item.spec.version) {
+ // Copying the file to a temporary location has some benefits. If the
+ // file is locked and cannot be read then we'll fall back to
+ // downloading a fresh copy. We later mark the install object with
+ // ownsTempFile so that we will cleanup later (see installAddonSet).
+ try {
+ let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile).path;
+ let uniquePath = await IOUtils.createUniqueFile(tmpDir, "tmpaddon");
+ await IOUtils.copy(sourceAddon._sourceBundle.path, uniquePath);
+ // Make sure to update file modification times so this is detected
+ // as a new add-on.
+ await IOUtils.setModificationTime(uniquePath);
+ item.path = uniquePath;
+ } catch (e) {
+ logger.warn(
+ `Failed make temporary copy of ${sourceAddon._sourceBundle.path}.`,
+ e
+ );
+ }
+ }
+ if (!item.path) {
+ item.path = await lazy.ProductAddonChecker.downloadAddon(item.spec);
+ }
+ item.addon = await loadManifestFromFile(
+ nsIFile(item.path),
+ systemAddonLocation
+ );
+ } catch (e) {
+ logger.error(`Failed to download system add-on ${item.spec.id}`, e);
+ }
+ }
+ await Promise.all(Array.from(addonList.values()).map(downloadAddon));
+
+ // The download promises all resolve regardless, now check if they all
+ // succeeded
+ let validateAddon = item => {
+ if (item.spec.id != item.addon.id) {
+ logger.warn(
+ `Downloaded system add-on expected to be ${item.spec.id} but was ${item.addon.id}.`
+ );
+ return false;
+ }
+
+ if (item.spec.version != item.addon.version) {
+ logger.warn(
+ `Expected system add-on ${item.spec.id} to be version ${item.spec.version} but was ${item.addon.version}.`
+ );
+ return false;
+ }
+
+ if (!installer.isValidAddon(item.addon)) {
+ return false;
+ }
+
+ return true;
+ };
+
+ if (
+ !Array.from(addonList.values()).every(
+ item => item.path && item.addon && validateAddon(item)
+ )
+ ) {
+ throw new Error(
+ "Rejecting updated system add-on set that either could not " +
+ "be downloaded or contained unusable add-ons."
+ );
+ }
+
+ // Install into the install location
+ logger.info("Installing new system add-on set");
+ await installer.installAddonSet(
+ Array.from(addonList.values()).map(a => a.addon)
+ );
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons is enabled.
+ *
+ * @returns {boolean}
+ * True if installing is enabled.
+ */
+ isInstallEnabled() {
+ // Default to enabled if the preference does not exist
+ return Services.prefs.getBoolPref(PREF_XPI_ENABLED, true);
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons by direct URL requests is
+ * whitelisted.
+ *
+ * @returns {boolean}
+ * True if installing by direct requests is whitelisted
+ */
+ isDirectRequestWhitelisted() {
+ // Default to whitelisted if the preference does not exist.
+ return Services.prefs.getBoolPref(PREF_XPI_DIRECT_WHITELISTED, true);
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons from file referrers is
+ * whitelisted.
+ *
+ * @returns {boolean}
+ * True if installing from file referrers is whitelisted
+ */
+ isFileRequestWhitelisted() {
+ // Default to whitelisted if the preference does not exist.
+ return Services.prefs.getBoolPref(PREF_XPI_FILE_WHITELISTED, true);
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons from a URI is allowed.
+ *
+ * @param {nsIPrincipal} aInstallingPrincipal
+ * The nsIPrincipal that initiated the install
+ * @returns {boolean}
+ * True if installing is allowed
+ */
+ isInstallAllowed(aInstallingPrincipal) {
+ if (!this.isInstallEnabled()) {
+ return false;
+ }
+
+ let uri = aInstallingPrincipal.URI;
+
+ // Direct requests without a referrer are either whitelisted or blocked.
+ if (!uri) {
+ return this.isDirectRequestWhitelisted();
+ }
+
+ // Local referrers can be whitelisted.
+ if (
+ this.isFileRequestWhitelisted() &&
+ (uri.schemeIs("chrome") || uri.schemeIs("file"))
+ ) {
+ return true;
+ }
+
+ XPIExports.XPIDatabase.importPermissions();
+
+ let permission = Services.perms.testPermissionFromPrincipal(
+ aInstallingPrincipal,
+ XPIExports.XPIInternal.XPI_PERMISSION
+ );
+ if (permission == Ci.nsIPermissionManager.DENY_ACTION) {
+ return false;
+ }
+
+ let requireWhitelist = Services.prefs.getBoolPref(
+ PREF_XPI_WHITELIST_REQUIRED,
+ true
+ );
+ if (
+ requireWhitelist &&
+ permission != Ci.nsIPermissionManager.ALLOW_ACTION
+ ) {
+ return false;
+ }
+
+ let requireSecureOrigin = Services.prefs.getBoolPref(
+ PREF_INSTALL_REQUIRESECUREORIGIN,
+ true
+ );
+ let safeSchemes = ["https", "chrome", "file"];
+ if (requireSecureOrigin && !safeSchemes.includes(uri.scheme)) {
+ return false;
+ }
+
+ return true;
+ },
+
+ /**
+ * Called to get an AddonInstall to download and install an add-on from a URL.
+ *
+ * @param {nsIURI} aUrl
+ * The URL to be installed
+ * @param {object} [aOptions]
+ * Additional options for this install.
+ * @param {string?} [aOptions.hash]
+ * A hash for the install
+ * @param {string} [aOptions.name]
+ * A name for the install
+ * @param {Object} [aOptions.icons]
+ * Icon URLs for the install
+ * @param {string} [aOptions.version]
+ * A version for the install
+ * @param {XULElement} [aOptions.browser]
+ * The browser performing the install
+ * @param {Object} [aOptions.telemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @param {boolean} [aOptions.sendCookies = false]
+ * Whether cookies should be sent when downloading the add-on.
+ * @param {string} [aOptions.useSystemLocation = false]
+ * If true installs to the system profile location.
+ * @returns {AddonInstall}
+ */
+ async getInstallForURL(aUrl, aOptions) {
+ let locationName = aOptions.useSystemLocation
+ ? XPIExports.XPIInternal.KEY_APP_SYSTEM_PROFILE
+ : XPIExports.XPIInternal.KEY_APP_PROFILE;
+ let location = XPIExports.XPIInternal.XPIStates.getLocation(locationName);
+ if (!location) {
+ throw Components.Exception(
+ "Invalid location name",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let url = Services.io.newURI(aUrl);
+
+ if (url instanceof Ci.nsIFileURL) {
+ let install = new LocalAddonInstall(location, url, aOptions);
+ await install.init();
+ return install.wrapper;
+ }
+
+ let install = new DownloadAddonInstall(location, url, aOptions);
+ return install.wrapper;
+ },
+
+ /**
+ * Called to get an AddonInstall to install an add-on from a local file.
+ *
+ * @param {nsIFile} aFile
+ * The file to be installed
+ * @param {Object?} [aInstallTelemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @param {boolean} [aUseSystemLocation = false]
+ * If true install to the system profile location.
+ * @returns {AddonInstall?}
+ */
+ async getInstallForFile(
+ aFile,
+ aInstallTelemetryInfo,
+ aUseSystemLocation = false
+ ) {
+ let location = XPIExports.XPIInternal.XPIStates.getLocation(
+ aUseSystemLocation
+ ? XPIExports.XPIInternal.KEY_APP_SYSTEM_PROFILE
+ : XPIExports.XPIInternal.KEY_APP_PROFILE
+ );
+ let install = await createLocalInstall(
+ aFile,
+ location,
+ aInstallTelemetryInfo
+ );
+ return install ? install.wrapper : null;
+ },
+
+ /**
+ * Called to get the current AddonInstalls, optionally limiting to a list of
+ * types.
+ *
+ * @param {Array<string>?} aTypes
+ * An array of types or null to get all types
+ * @returns {AddonInstall[]}
+ */
+ getInstallsByTypes(aTypes) {
+ let results = [...this.installs];
+ if (aTypes) {
+ results = results.filter(install => {
+ return aTypes.includes(install.type);
+ });
+ }
+
+ return results.map(install => install.wrapper);
+ },
+
+ /**
+ * Temporarily installs add-on from a local XPI file or directory.
+ * As this is intended for development, the signature is not checked and
+ * the add-on does not persist on application restart.
+ *
+ * @param {nsIFile} aFile
+ * An nsIFile for the unpacked add-on directory or XPI file.
+ *
+ * @returns {Promise<Addon>}
+ * A Promise that resolves to an Addon object on success, or rejects
+ * if the add-on is not a valid restartless add-on or if the
+ * same ID is already installed.
+ */
+ async installTemporaryAddon(aFile) {
+ let installLocation = XPIExports.XPIInternal.TemporaryInstallLocation;
+
+ if (XPIExports.XPIInternal.isXPI(aFile.leafName)) {
+ flushJarCache(aFile);
+ }
+ let addon = await loadManifestFromFile(aFile, installLocation);
+ addon.rootURI = XPIExports.XPIInternal.getURIForResourceInFile(
+ aFile,
+ ""
+ ).spec;
+
+ await this._activateAddon(addon, { temporarilyInstalled: true });
+
+ logger.debug(`Install of temporary addon in ${aFile.path} completed.`);
+ return addon.wrapper;
+ },
+
+ /**
+ * Installs an add-on from a built-in location
+ * (ie a resource: url referencing assets shipped with the application)
+ *
+ * @param {string} base
+ * A string containing the base URL. Must be a resource: URL.
+ * @returns {Promise<Addon>}
+ * A Promise that resolves to an Addon object when the addon is
+ * installed.
+ */
+ async installBuiltinAddon(base) {
+ // We have to get this before the install, as the install will overwrite
+ // the pref. We then keep the value for this run, so we can restore
+ // the selected theme once it becomes available.
+ if (lastSelectedTheme === null) {
+ lastSelectedTheme = Services.prefs.getCharPref(PREF_SELECTED_THEME, "");
+ }
+
+ let baseURL = Services.io.newURI(base);
+
+ // WebExtensions need to be able to iterate through the contents of
+ // an extension (for localization). It knows how to do this with
+ // jar: and file: URLs, so translate the provided base URL to
+ // something it can use.
+ if (baseURL.scheme !== "resource") {
+ throw new Error("Built-in addons must use resource: URLS");
+ }
+
+ let pkg = builtinPackage(baseURL);
+ let addon = await loadManifest(pkg, XPIExports.XPIInternal.BuiltInLocation);
+ addon.rootURI = base;
+
+ // If this is a theme, decide whether to enable it. Themes are
+ // disabled by default. However:
+ //
+ // We always want one theme to be active, falling back to the
+ // default theme when the active theme is disabled.
+ // During a theme migration, such as a change in the path to the addon, we
+ // will need to ensure a correct theme is enabled.
+ if (addon.type === "theme") {
+ if (
+ addon.id === lastSelectedTheme ||
+ (!lastSelectedTheme.endsWith("@mozilla.org") &&
+ addon.id === lazy.AddonSettings.DEFAULT_THEME_ID &&
+ !XPIExports.XPIDatabase.getAddonsByType("theme").some(
+ theme => !theme.disabled
+ ))
+ ) {
+ addon.userDisabled = false;
+ }
+ }
+ await this._activateAddon(addon);
+ return addon.wrapper;
+ },
+
+ /**
+ * Activate a newly installed addon.
+ * This function handles all the bookkeeping related to a new addon
+ * and invokes whatever bootstrap methods are necessary.
+ * Note that this function is only used for temporary and built-in
+ * installs, it is very similar to AddonInstall::startInstall().
+ * It would be great to merge this function with that one some day.
+ *
+ * @param {AddonInternal} addon The addon to activate
+ * @param {object} [extraParams] Any extra parameters to pass to the
+ * bootstrap install() method
+ *
+ * @returns {Promise<void>}
+ */
+ async _activateAddon(addon, extraParams = {}) {
+ if (addon.appDisabled) {
+ let message = `Add-on ${addon.id} is not compatible with application version.`;
+
+ let app = addon.matchingTargetApplication;
+ if (app) {
+ if (app.minVersion) {
+ message += ` add-on minVersion: ${app.minVersion}.`;
+ }
+ if (app.maxVersion) {
+ message += ` add-on maxVersion: ${app.maxVersion}.`;
+ }
+ }
+ throw new Error(message);
+ }
+
+ let oldAddon = await XPIExports.XPIDatabase.getVisibleAddonForID(addon.id);
+
+ let willActivate =
+ !oldAddon ||
+ oldAddon.location == addon.location ||
+ addon.location.hasPrecedence(oldAddon.location);
+
+ let install = () => {
+ addon.visible = willActivate;
+ // Themes are generally not enabled by default at install time,
+ // unless enabled by the front-end code. If they are meant to be
+ // enabled, they will already have been enabled by this point.
+ if (addon.type !== "theme" || addon.location.isTemporary) {
+ addon.userDisabled = false;
+ }
+ addon.active = addon.visible && !addon.disabled;
+
+ addon = XPIExports.XPIDatabase.addToDatabase(
+ addon,
+ addon._sourceBundle ? addon._sourceBundle.path : null
+ );
+
+ XPIExports.XPIInternal.XPIStates.addAddon(addon);
+ XPIExports.XPIInternal.XPIStates.save();
+ };
+
+ AddonManagerPrivate.callAddonListeners("onInstalling", addon.wrapper);
+
+ if (!willActivate) {
+ addon.installDate = Date.now();
+
+ install();
+ } else if (oldAddon) {
+ logger.warn(
+ `Addon with ID ${oldAddon.id} already installed, ` +
+ "older version will be disabled"
+ );
+
+ addon.installDate = oldAddon.installDate;
+
+ await XPIExports.XPIInternal.BootstrapScope.get(oldAddon).update(
+ addon,
+ true,
+ install
+ );
+ } else {
+ addon.installDate = Date.now();
+
+ install();
+ let bootstrap = XPIExports.XPIInternal.BootstrapScope.get(addon);
+ await bootstrap.install(undefined, true, extraParams);
+ }
+
+ AddonManagerPrivate.callInstallListeners(
+ "onExternalInstall",
+ null,
+ addon.wrapper,
+ oldAddon ? oldAddon.wrapper : null,
+ false
+ );
+ AddonManagerPrivate.callAddonListeners("onInstalled", addon.wrapper);
+
+ // Notify providers that a new theme has been enabled.
+ if (addon.type === "theme" && !addon.userDisabled) {
+ AddonManagerPrivate.notifyAddonChanged(addon.id, addon.type, false);
+ }
+ },
+
+ /**
+ * Uninstalls an add-on, immediately if possible or marks it as pending
+ * uninstall if not.
+ *
+ * @param {DBAddonInternal} aAddon
+ * The DBAddonInternal to uninstall
+ * @param {boolean} aForcePending
+ * Force this addon into the pending uninstall state (used
+ * e.g. while the add-on manager is open and offering an
+ * "undo" button)
+ * @throws if the addon cannot be uninstalled because it is in an install
+ * location that does not allow it
+ */
+ async uninstallAddon(aAddon, aForcePending) {
+ if (!aAddon.inDatabase) {
+ throw new Error(
+ `Cannot uninstall addon ${aAddon.id} because it is not installed`
+ );
+ }
+ let { location } = aAddon;
+
+ // If the addon is sideloaded into a location that does not allow
+ // sideloads, it is a legacy sideload. We allow those to be uninstalled.
+ let isLegacySideload =
+ aAddon.foreignInstall &&
+ !(location.scope & lazy.AddonSettings.SCOPES_SIDELOAD);
+
+ if (location.locked && !isLegacySideload) {
+ throw new Error(
+ `Cannot uninstall addon ${aAddon.id} ` +
+ `from locked install location ${location.name}`
+ );
+ }
+
+ if (aForcePending && aAddon.pendingUninstall) {
+ throw new Error("Add-on is already marked to be uninstalled");
+ }
+
+ if (aAddon._updateCheck) {
+ logger.debug(`Cancel in-progress update check for ${aAddon.id}`);
+ aAddon._updateCheck.cancel();
+ }
+
+ let wasActive = aAddon.active;
+ let wasPending = aAddon.pendingUninstall;
+
+ if (aForcePending) {
+ // We create an empty directory in the staging directory to indicate
+ // that an uninstall is necessary on next startup. Temporary add-ons are
+ // automatically uninstalled on shutdown anyway so there is no need to
+ // do this for them.
+ if (!aAddon.location.isTemporary && aAddon.location.installer) {
+ let stage = getFile(
+ aAddon.id,
+ aAddon.location.installer.getStagingDir()
+ );
+ if (!stage.exists()) {
+ stage.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ }
+ }
+
+ XPIExports.XPIDatabase.setAddonProperties(aAddon, {
+ pendingUninstall: true,
+ });
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+ let xpiState = aAddon.location.get(aAddon.id);
+ if (xpiState) {
+ xpiState.enabled = false;
+ XPIExports.XPIInternal.XPIStates.save();
+ } else {
+ logger.warn(
+ "Can't find XPI state while uninstalling ${id} from ${location}",
+ aAddon
+ );
+ }
+ }
+
+ // If the add-on is not visible then there is no need to notify listeners.
+ if (!aAddon.visible) {
+ return;
+ }
+
+ let wrapper = aAddon.wrapper;
+
+ // If the add-on wasn't already pending uninstall then notify listeners.
+ if (!wasPending) {
+ AddonManagerPrivate.callAddonListeners(
+ "onUninstalling",
+ wrapper,
+ !!aForcePending
+ );
+ }
+
+ let existingAddon = XPIExports.XPIInternal.XPIStates.findAddon(
+ aAddon.id,
+ loc => loc != aAddon.location
+ );
+
+ let bootstrap = XPIExports.XPIInternal.BootstrapScope.get(aAddon);
+ if (!aForcePending) {
+ let existing;
+ if (existingAddon) {
+ existing = await XPIExports.XPIDatabase.getAddonInLocation(
+ aAddon.id,
+ existingAddon.location.name
+ );
+ }
+
+ let uninstall = () => {
+ XPIExports.XPIInternal.XPIStates.disableAddon(aAddon.id);
+ if (aAddon.location.installer) {
+ aAddon.location.installer.uninstallAddon(aAddon.id);
+ }
+ XPIExports.XPIDatabase.removeAddonMetadata(aAddon);
+ aAddon.location.removeAddon(aAddon.id);
+ AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper);
+
+ if (existing) {
+ // Migrate back to the existing addon, unless it was a builtin colorway theme,
+ // in that case we also make sure to remove the addon from the builtin location.
+ if (
+ existing.isBuiltinColorwayTheme &&
+ XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ ) {
+ existing.location.removeAddon(existing.id);
+ } else {
+ XPIExports.XPIDatabase.makeAddonVisible(existing);
+ AddonManagerPrivate.callAddonListeners(
+ "onInstalling",
+ existing.wrapper,
+ false
+ );
+
+ if (!existing.disabled) {
+ XPIExports.XPIDatabase.updateAddonActive(existing, true);
+ }
+ }
+ }
+ };
+
+ // Migrate back to the existing addon, unless it was a builtin colorway theme.
+ if (
+ existing &&
+ !(
+ existing.isBuiltinColorwayTheme &&
+ XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ )
+ ) {
+ await bootstrap.update(existing, !existing.disabled, uninstall);
+
+ AddonManagerPrivate.callAddonListeners("onInstalled", existing.wrapper);
+ } else {
+ aAddon.location.removeAddon(aAddon.id);
+ await bootstrap.uninstall();
+ uninstall();
+ }
+ } else if (aAddon.active) {
+ XPIExports.XPIInternal.XPIStates.disableAddon(aAddon.id);
+ bootstrap.shutdown(
+ XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_UNINSTALL
+ );
+ XPIExports.XPIDatabase.updateAddonActive(aAddon, false);
+ }
+
+ // Notify any other providers that a new theme has been enabled
+ // (when the active theme is uninstalled, the default theme is enabled).
+ if (aAddon.type === "theme" && wasActive) {
+ AddonManagerPrivate.notifyAddonChanged(null, aAddon.type);
+ }
+ },
+
+ /**
+ * Cancels the pending uninstall of an add-on.
+ *
+ * @param {DBAddonInternal} aAddon
+ * The DBAddonInternal to cancel uninstall for
+ */
+ cancelUninstallAddon(aAddon) {
+ if (!aAddon.inDatabase) {
+ throw new Error("Can only cancel uninstall for installed addons.");
+ }
+ if (!aAddon.pendingUninstall) {
+ throw new Error("Add-on is not marked to be uninstalled");
+ }
+
+ if (!aAddon.location.isTemporary && aAddon.location.installer) {
+ aAddon.location.installer.cleanStagingDir([aAddon.id]);
+ }
+
+ XPIExports.XPIDatabase.setAddonProperties(aAddon, {
+ pendingUninstall: false,
+ });
+
+ if (!aAddon.visible) {
+ return;
+ }
+
+ aAddon.location.get(aAddon.id).syncWithDB(aAddon);
+ XPIExports.XPIInternal.XPIStates.save();
+
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+
+ if (!aAddon.disabled) {
+ XPIExports.XPIInternal.BootstrapScope.get(aAddon).startup(
+ XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_INSTALL
+ );
+ XPIExports.XPIDatabase.updateAddonActive(aAddon, true);
+ }
+
+ let wrapper = aAddon.wrapper;
+ AddonManagerPrivate.callAddonListeners("onOperationCancelled", wrapper);
+
+ // Notify any other providers that this theme is now enabled again.
+ if (aAddon.type === "theme" && aAddon.active) {
+ AddonManagerPrivate.notifyAddonChanged(aAddon.id, aAddon.type, false);
+ }
+ },
+
+ DirectoryInstaller,
+ SystemAddonInstaller,
+};
diff --git a/toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs b/toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs
new file mode 100644
index 0000000000..d5ffd06d11
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs
@@ -0,0 +1,3377 @@
+/* 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/. */
+
+/**
+ * This file contains most of the logic required to load and run
+ * extensions at startup. Anything which is not required immediately at
+ * startup should go in XPIInstall.sys.mjs or XPIDatabase.sys.mjs if at all
+ * possible, in order to minimize the impact on startup performance.
+ */
+
+/**
+ * @typedef {number} integer
+ */
+
+/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { XPIExports } from "resource://gre/modules/addons/XPIExports.sys.mjs";
+import {
+ AddonManager,
+ AddonManagerPrivate,
+} from "resource://gre/modules/AddonManager.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ Dictionary: "resource://gre/modules/Extension.sys.mjs",
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+ Langpack: "resource://gre/modules/Extension.sys.mjs",
+ SitePermission: "resource://gre/modules/Extension.sys.mjs",
+ TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ aomStartup: [
+ "@mozilla.org/addons/addon-manager-startup;1",
+ "amIAddonManagerStartup",
+ ],
+ resProto: [
+ "@mozilla.org/network/protocol;1?name=resource",
+ "nsISubstitutingProtocolHandler",
+ ],
+ spellCheck: ["@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine"],
+ timerManager: [
+ "@mozilla.org/updates/timer-manager;1",
+ "nsIUpdateTimerManager",
+ ],
+});
+
+const nsIFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+const FileInputStream = Components.Constructor(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+
+const PREF_DB_SCHEMA = "extensions.databaseSchema";
+const PREF_PENDING_OPERATIONS = "extensions.pendingOperations";
+const PREF_EM_ENABLED_SCOPES = "extensions.enabledScopes";
+const PREF_EM_STARTUP_SCAN_SCOPES = "extensions.startupScanScopes";
+// xpinstall.signatures.required only supported in dev builds
+const PREF_XPI_SIGNATURES_REQUIRED = "xpinstall.signatures.required";
+const PREF_LANGPACK_SIGNATURES = "extensions.langpacks.signatures.required";
+const PREF_INSTALL_DISTRO_ADDONS = "extensions.installDistroAddons";
+const PREF_BRANCH_INSTALLED_ADDON = "extensions.installedDistroAddon.";
+const PREF_SYSTEM_ADDON_SET = "extensions.systemAddonSet";
+
+const PREF_EM_LAST_APP_BUILD_ID = "extensions.lastAppBuildId";
+
+// Specify a list of valid built-in add-ons to load.
+const BUILT_IN_ADDONS_URI = "chrome://browser/content/built_in_addons.json";
+
+const DIR_EXTENSIONS = "extensions";
+const DIR_SYSTEM_ADDONS = "features";
+const DIR_APP_SYSTEM_PROFILE = "system-extensions";
+const DIR_STAGE = "staged";
+const DIR_TRASH = "trash";
+
+const FILE_XPI_STATES = "addonStartup.json.lz4";
+
+const KEY_PROFILEDIR = "ProfD";
+const KEY_ADDON_APP_DIR = "XREAddonAppDir";
+const KEY_APP_DISTRIBUTION = "XREAppDist";
+const KEY_APP_FEATURES = "XREAppFeat";
+
+const KEY_APP_PROFILE = "app-profile";
+const KEY_APP_SYSTEM_PROFILE = "app-system-profile";
+const KEY_APP_SYSTEM_ADDONS = "app-system-addons";
+const KEY_APP_SYSTEM_DEFAULTS = "app-system-defaults";
+const KEY_APP_BUILTINS = "app-builtin";
+const KEY_APP_GLOBAL = "app-global";
+const KEY_APP_SYSTEM_LOCAL = "app-system-local";
+const KEY_APP_SYSTEM_SHARE = "app-system-share";
+const KEY_APP_SYSTEM_USER = "app-system-user";
+const KEY_APP_TEMPORARY = "app-temporary";
+
+const TEMPORARY_ADDON_SUFFIX = "@temporary-addon";
+
+const STARTUP_MTIME_SCOPES = [
+ KEY_APP_GLOBAL,
+ KEY_APP_SYSTEM_LOCAL,
+ KEY_APP_SYSTEM_SHARE,
+ KEY_APP_SYSTEM_USER,
+];
+
+const NOTIFICATION_FLUSH_PERMISSIONS = "flush-pending-permissions";
+const XPI_PERMISSION = "install";
+
+const XPI_SIGNATURE_CHECK_PERIOD = 24 * 60 * 60;
+
+const DB_SCHEMA = 35;
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "enabledScopesPref",
+ PREF_EM_ENABLED_SCOPES,
+ AddonManager.SCOPE_ALL
+);
+
+Object.defineProperty(lazy, "enabledScopes", {
+ get() {
+ // The profile location is always enabled
+ return lazy.enabledScopesPref | AddonManager.SCOPE_PROFILE;
+ },
+});
+
+function encoded(strings, ...values) {
+ let result = [];
+
+ for (let [i, string] of strings.entries()) {
+ result.push(string);
+ if (i < values.length) {
+ result.push(encodeURIComponent(values[i]));
+ }
+ }
+
+ return result.join("");
+}
+
+const BOOTSTRAP_REASONS = {
+ APP_STARTUP: 1,
+ APP_SHUTDOWN: 2,
+ ADDON_ENABLE: 3,
+ ADDON_DISABLE: 4,
+ ADDON_INSTALL: 5,
+ ADDON_UNINSTALL: 6,
+ ADDON_UPGRADE: 7,
+ ADDON_DOWNGRADE: 8,
+};
+
+// All addonTypes supported by the XPIProvider. These values can be passed to
+// AddonManager.getAddonsByTypes in order to get XPIProvider.getAddonsByTypes
+// to return only supported add-ons. Without these, it is possible for
+// AddonManager.getAddonsByTypes to return addons from other providers, or even
+// add-on types that are no longer supported by XPIProvider.
+const ALL_XPI_TYPES = new Set([
+ "dictionary",
+ "extension",
+ "locale",
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ "sitepermission-deprecated",
+ "theme",
+]);
+
+/**
+ * Valid IDs fit this pattern.
+ */
+var gIDTest =
+ /^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i;
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.xpi";
+
+// Create a new logger for use by all objects in this Addons XPI Provider module
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+/**
+ * Spins the event loop until the given promise resolves, and then eiter returns
+ * its success value or throws its rejection value.
+ *
+ * @param {Promise} promise
+ * The promise to await.
+ * @returns {any}
+ * The promise's resolution value, if any.
+ */
+function awaitPromise(promise) {
+ let success = undefined;
+ let result = null;
+
+ promise.then(
+ val => {
+ success = true;
+ result = val;
+ },
+ val => {
+ success = false;
+ result = val;
+ }
+ );
+
+ Services.tm.spinEventLoopUntil(
+ "XPIProvider.sys.mjs:awaitPromise",
+ () => success !== undefined
+ );
+
+ if (!success) {
+ throw result;
+ }
+ return result;
+}
+
+/**
+ * Returns a nsIFile instance for the given path, relative to the given
+ * base file, if provided.
+ *
+ * @param {string} path
+ * The (possibly relative) path of the file.
+ * @param {nsIFile} [base]
+ * An optional file to use as a base path if `path` is relative.
+ * @returns {nsIFile}
+ */
+function getFile(path, base = null) {
+ // First try for an absolute path, as we get in the case of proxy
+ // files. Ideally we would try a relative path first, but on Windows,
+ // paths which begin with a drive letter are valid as relative paths,
+ // and treated as such.
+ try {
+ return new nsIFile(path);
+ } catch (e) {
+ // Ignore invalid relative paths. The only other error we should see
+ // here is EOM, and either way, any errors that we care about should
+ // be re-thrown below.
+ }
+
+ // If the path isn't absolute, we must have a base path.
+ let file = base.clone();
+ file.appendRelativePath(path);
+ return file;
+}
+
+/**
+ * Returns true if the given file, based on its name, should be treated
+ * as an XPI. If the file does not have an appropriate extension, it is
+ * assumed to be an unpacked add-on.
+ *
+ * @param {string} filename
+ * The filename to check.
+ * @param {boolean} [strict = false]
+ * If true, this file is in a location maintained by the browser, and
+ * must have a strict, lower-case ".xpi" extension.
+ * @returns {boolean}
+ * True if the file is an XPI.
+ */
+function isXPI(filename, strict) {
+ if (strict) {
+ return filename.endsWith(".xpi");
+ }
+ let ext = filename.slice(-4).toLowerCase();
+ return ext === ".xpi" || ext === ".zip";
+}
+
+/**
+ * Returns the extension expected ID for a given file in an extension install
+ * directory.
+ *
+ * @param {nsIFile} file
+ * The extension XPI file or unpacked directory.
+ * @returns {AddonId?}
+ * The add-on ID, if valid, or null otherwise.
+ */
+function getExpectedID(file) {
+ let { leafName } = file;
+ let id = isXPI(leafName, true) ? leafName.slice(0, -4) : leafName;
+ if (gIDTest.test(id)) {
+ return id;
+ }
+ return null;
+}
+
+/**
+ * Evaluates whether an add-on is allowed to run in safe mode.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to check
+ * @returns {boolean}
+ * True if the add-on should run in safe mode
+ */
+function canRunInSafeMode(aAddon) {
+ let location = aAddon.location || null;
+ if (!location) {
+ return false;
+ }
+
+ // Even though the updated system add-ons aren't generally run in safe mode we
+ // include them here so their uninstall functions get called when switching
+ // back to the default set.
+
+ // TODO product should make the call about temporary add-ons running
+ // in safe mode. assuming for now that they are.
+ return location.isTemporary || location.isSystem || location.isBuiltin;
+}
+
+/**
+ * Gets an nsIURI for a file within another file, either a directory or an XPI
+ * file. If aFile is a directory then this will return a file: URI, if it is an
+ * XPI file then it will return a jar: URI.
+ *
+ * @param {nsIFile} aFile
+ * The file containing the resources, must be either a directory or an
+ * XPI file
+ * @param {string} aPath
+ * The path to find the resource at, "/" separated. If aPath is empty
+ * then the uri to the root of the contained files will be returned
+ * @returns {nsIURI}
+ * An nsIURI pointing at the resource
+ */
+function getURIForResourceInFile(aFile, aPath) {
+ if (!isXPI(aFile.leafName)) {
+ let resource = aFile.clone();
+ if (aPath) {
+ aPath.split("/").forEach(part => resource.append(part));
+ }
+
+ return Services.io.newFileURI(resource);
+ }
+
+ return buildJarURI(aFile, aPath);
+}
+
+/**
+ * Creates a jar: URI for a file inside a ZIP file.
+ *
+ * @param {nsIFile} aJarfile
+ * The ZIP file as an nsIFile
+ * @param {string} aPath
+ * The path inside the ZIP file
+ * @returns {nsIURI}
+ * An nsIURI for the file
+ */
+function buildJarURI(aJarfile, aPath) {
+ let uri = Services.io.newFileURI(aJarfile);
+ uri = "jar:" + uri.spec + "!/" + aPath;
+ return Services.io.newURI(uri);
+}
+
+function maybeResolveURI(uri) {
+ if (uri.schemeIs("resource")) {
+ return Services.io.newURI(lazy.resProto.resolveURI(uri));
+ }
+ return uri;
+}
+
+/**
+ * Iterates over the entries in a given directory.
+ *
+ * Fails silently if the given directory does not exist.
+ *
+ * @param {nsIFile} aDir
+ * Directory to iterate.
+ */
+function* iterDirectory(aDir) {
+ let dirEnum;
+ try {
+ dirEnum = aDir.directoryEntries;
+ let file;
+ while ((file = dirEnum.nextFile)) {
+ yield file;
+ }
+ } catch (e) {
+ if (aDir.exists()) {
+ logger.warn(`Can't iterate directory ${aDir.path}`, e);
+ }
+ } finally {
+ if (dirEnum) {
+ dirEnum.close();
+ }
+ }
+}
+
+/**
+ * Migrate data about an addon to match the change made in bug 857456
+ * in which "webextension-foo" types were converted to "foo" and the
+ * "loader" property was added to distinguish different addon types.
+ *
+ * @param {Object} addon The addon info to migrate.
+ * @returns {boolean} True if the addon data was converted, false if not.
+ */
+function migrateAddonLoader(addon) {
+ if (addon.hasOwnProperty("loader")) {
+ return false;
+ }
+
+ switch (addon.type) {
+ case "extension":
+ case "dictionary":
+ case "locale":
+ case "theme":
+ addon.loader = "bootstrap";
+ break;
+
+ case "webextension":
+ addon.type = "extension";
+ addon.loader = null;
+ break;
+
+ case "webextension-dictionary":
+ addon.type = "dictionary";
+ addon.loader = null;
+ break;
+
+ case "webextension-langpack":
+ addon.type = "locale";
+ addon.loader = null;
+ break;
+
+ case "webextension-theme":
+ addon.type = "theme";
+ addon.loader = null;
+ break;
+
+ default:
+ logger.warn(`Not converting unknown addon type ${addon.type}`);
+ }
+ return true;
+}
+
+/**
+ * The on-disk state of an individual XPI, created from an Object
+ * as stored in the addonStartup.json file.
+ */
+const JSON_FIELDS = Object.freeze([
+ "dependencies",
+ "enabled",
+ "file",
+ "loader",
+ "lastModifiedTime",
+ "path",
+ "recommendationState",
+ "rootURI",
+ "runInSafeMode",
+ "signedState",
+ "signedDate",
+ "startupData",
+ "telemetryKey",
+ "type",
+ "version",
+]);
+
+class XPIState {
+ constructor(location, id, saved = {}) {
+ this.location = location;
+ this.id = id;
+
+ // Set default values.
+ this.type = "extension";
+
+ for (let prop of JSON_FIELDS) {
+ if (prop in saved) {
+ this[prop] = saved[prop];
+ }
+ }
+
+ // Builds prior to be 1512436 did not include the rootURI property.
+ // If we're updating from such a build, add that property now.
+ if (!("rootURI" in this) && this.file) {
+ this.rootURI = getURIForResourceInFile(this.file, "").spec;
+ }
+
+ if (!this.telemetryKey) {
+ this.telemetryKey = this.getTelemetryKey();
+ }
+
+ if (
+ saved.currentModifiedTime &&
+ saved.currentModifiedTime != this.lastModifiedTime
+ ) {
+ this.lastModifiedTime = saved.currentModifiedTime;
+ } else if (saved.currentModifiedTime === null) {
+ this.missing = true;
+ }
+ }
+
+ // Compatibility shim getters for legacy callers in XPIDatabase.sys.mjs.
+ get mtime() {
+ return this.lastModifiedTime;
+ }
+ get active() {
+ return this.enabled;
+ }
+
+ /**
+ * @property {string} path
+ * The full on-disk path of the add-on.
+ */
+ get path() {
+ return this.file && this.file.path;
+ }
+ set path(path) {
+ this.file = path ? getFile(path, this.location.dir) : null;
+ }
+
+ /**
+ * @property {string} relativePath
+ * The path to the add-on relative to its parent location, or
+ * the full path if its parent location has no on-disk path.
+ */
+ get relativePath() {
+ if (this.location.dir && this.location.dir.contains(this.file)) {
+ let path = this.file.getRelativePath(this.location.dir);
+ if (AppConstants.platform == "win") {
+ path = path.replace(/\//g, "\\");
+ }
+ return path;
+ }
+ return this.path;
+ }
+
+ /**
+ * Returns a JSON-compatible representation of this add-on's state
+ * data, to be saved to addonStartup.json.
+ *
+ * @returns {Object}
+ */
+ toJSON() {
+ let json = {
+ dependencies: this.dependencies,
+ enabled: this.enabled,
+ lastModifiedTime: this.lastModifiedTime,
+ loader: this.loader,
+ path: this.relativePath,
+ recommendationState: this.recommendationState,
+ rootURI: this.rootURI,
+ runInSafeMode: this.runInSafeMode,
+ signedState: this.signedState,
+ signedDate: this.signedDate,
+ telemetryKey: this.telemetryKey,
+ version: this.version,
+ };
+ if (this.type != "extension") {
+ json.type = this.type;
+ }
+ if (this.startupData) {
+ json.startupData = this.startupData;
+ }
+ return json;
+ }
+
+ get isWebExtension() {
+ return this.loader == null;
+ }
+
+ get isPrivileged() {
+ return lazy.ExtensionData.getIsPrivileged({
+ signedState: this.signedState,
+ builtIn: this.location.isBuiltin,
+ temporarilyInstalled: this.location.isTemporary,
+ });
+ }
+
+ /**
+ * Update the last modified time for an add-on on disk.
+ *
+ * @param {nsIFile} aFile
+ * The location of the add-on.
+ * @returns {boolean}
+ * True if the time stamp has changed.
+ */
+ getModTime(aFile) {
+ let mtime = 0;
+ try {
+ // Clone the file object so we always get the actual mtime, rather
+ // than whatever value it may have cached.
+ mtime = aFile.clone().lastModifiedTime;
+ } catch (e) {
+ logger.warn("Can't get modified time of ${path}", aFile, e);
+ }
+
+ let changed = mtime != this.lastModifiedTime;
+ this.lastModifiedTime = mtime;
+ return changed;
+ }
+
+ /**
+ * Returns a string key by which to identify this add-on in telemetry
+ * and crash reports.
+ *
+ * @returns {string}
+ */
+ getTelemetryKey() {
+ return encoded`${this.id}:${this.version}`;
+ }
+
+ get resolvedRootURI() {
+ return maybeResolveURI(Services.io.newURI(this.rootURI));
+ }
+
+ /**
+ * Update the XPIState to match an XPIDatabase entry; if 'enabled' is changed to true,
+ * update the last-modified time. This should probably be made async, but for now we
+ * don't want to maintain parallel sync and async versions of the scan.
+ *
+ * Caller is responsible for doing XPIStates.save() if necessary.
+ *
+ * @param {DBAddonInternal} aDBAddon
+ * The DBAddonInternal for this add-on.
+ * @param {boolean} [aUpdated = false]
+ * The add-on was updated, so we must record new modified time.
+ */
+ syncWithDB(aDBAddon, aUpdated = false) {
+ logger.debug("Updating XPIState for " + JSON.stringify(aDBAddon));
+ // If the add-on changes from disabled to enabled, we should re-check the modified time.
+ // If this is a newly found add-on, it won't have an 'enabled' field but we
+ // did a full recursive scan in that case, so we don't need to do it again.
+ // We don't use aDBAddon.active here because it's not updated until after restart.
+ let mustGetMod = aDBAddon.visible && !aDBAddon.disabled && !this.enabled;
+
+ this.enabled = aDBAddon.visible && !aDBAddon.disabled;
+
+ this.version = aDBAddon.version;
+ this.type = aDBAddon.type;
+ this.loader = aDBAddon.loader;
+
+ if (aDBAddon.startupData) {
+ this.startupData = aDBAddon.startupData;
+ }
+
+ this.telemetryKey = this.getTelemetryKey();
+
+ this.dependencies = aDBAddon.dependencies;
+ this.runInSafeMode = canRunInSafeMode(aDBAddon);
+ this.signedState = aDBAddon.signedState;
+ this.signedDate = aDBAddon.signedDate;
+ this.file = aDBAddon._sourceBundle;
+ this.rootURI = aDBAddon.rootURI;
+ this.recommendationState = aDBAddon.recommendationState;
+
+ if ((aUpdated || mustGetMod) && this.file) {
+ this.getModTime(this.file);
+ if (this.lastModifiedTime != aDBAddon.updateDate) {
+ aDBAddon.updateDate = this.lastModifiedTime;
+ if (XPIExports.XPIDatabase.initialized) {
+ XPIExports.XPIDatabase.saveChanges();
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Manages the state data for add-ons in a given install location.
+ *
+ * @param {string} name
+ * The name of the install location (e.g., "app-profile").
+ * @param {string | nsIFile | null} path
+ * The on-disk path of the install location. May be null for some
+ * locations which do not map to a specific on-disk path.
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ * @param {object} [saved]
+ * The persisted JSON state data to restore.
+ */
+class XPIStateLocation extends Map {
+ constructor(name, path, scope, saved) {
+ super();
+
+ this.name = name;
+ this.scope = scope;
+ if (path instanceof Ci.nsIFile) {
+ this.dir = path;
+ this.path = path.path;
+ } else {
+ this.path = path;
+ this.dir = this.path && new nsIFile(this.path);
+ }
+ this.staged = {};
+ this.changed = false;
+
+ if (saved) {
+ this.restore(saved);
+ }
+
+ this._installer = undefined;
+ }
+
+ hasPrecedence(otherLocation) {
+ let locations = Array.from(XPIStates.locations());
+ return locations.indexOf(this) <= locations.indexOf(otherLocation);
+ }
+
+ get installer() {
+ if (this._installer === undefined) {
+ this._installer = this.makeInstaller();
+ }
+ return this._installer;
+ }
+
+ makeInstaller() {
+ return null;
+ }
+
+ restore(saved) {
+ if (!this.path && saved.path) {
+ this.path = saved.path;
+ this.dir = new nsIFile(this.path);
+ }
+ this.staged = saved.staged || {};
+ this.changed = saved.changed || false;
+
+ for (let [id, data] of Object.entries(saved.addons || {})) {
+ let xpiState = this._addState(id, data);
+
+ // Make a note that this state was restored from saved data. But
+ // only if this location hasn't moved since the last startup,
+ // since that causes problems for new system add-on bundles.
+ if (!this.path || this.path == saved.path) {
+ xpiState.wasRestored = true;
+ }
+ }
+ }
+
+ /**
+ * Returns a JSON-compatible representation of this location's state
+ * data, to be saved to addonStartup.json.
+ *
+ * @returns {Object}
+ */
+ toJSON() {
+ let json = {
+ addons: {},
+ staged: this.staged,
+ };
+
+ if (this.path) {
+ json.path = this.path;
+ }
+
+ if (STARTUP_MTIME_SCOPES.includes(this.name)) {
+ json.checkStartupModifications = true;
+ }
+
+ for (let [id, addon] of this.entries()) {
+ json.addons[id] = addon;
+ }
+ return json;
+ }
+
+ get hasStaged() {
+ for (let key in this.staged) {
+ return true;
+ }
+ return false;
+ }
+
+ _addState(addonId, saved) {
+ let xpiState = new XPIState(this, addonId, saved);
+ this.set(addonId, xpiState);
+ return xpiState;
+ }
+
+ /**
+ * Adds state data for the given DB add-on to the DB.
+ *
+ * @param {DBAddon} addon
+ * The DBAddon to add.
+ */
+ addAddon(addon) {
+ logger.debug(
+ "XPIStates adding add-on ${id} in ${location}: ${path}",
+ addon
+ );
+
+ XPIProvider.persistStartupData(addon);
+
+ let xpiState = this._addState(addon.id, { file: addon._sourceBundle });
+ xpiState.syncWithDB(addon, true);
+
+ XPIProvider.addTelemetry(addon.id, { location: this.name });
+ }
+
+ /**
+ * Remove the XPIState for an add-on and save the new state.
+ *
+ * @param {string} aId
+ * The ID of the add-on.
+ */
+ removeAddon(aId) {
+ if (this.has(aId)) {
+ this.delete(aId);
+ XPIStates.save();
+ }
+ }
+
+ /**
+ * Adds stub state data for the local file to the DB.
+ *
+ * @param {string} addonId
+ * The ID of the add-on represented by the given file.
+ * @param {nsIFile} file
+ * The local file or directory containing the add-on.
+ * @returns {XPIState}
+ */
+ addFile(addonId, file) {
+ let xpiState = this._addState(addonId, {
+ enabled: false,
+ file: file.clone(),
+ });
+ xpiState.getModTime(xpiState.file);
+ return xpiState;
+ }
+
+ /**
+ * Adds metadata for a staged install which should be performed after
+ * the next restart.
+ *
+ * @param {string} addonId
+ * The ID of the staged install. The leaf name of the XPI
+ * within the location's staging directory must correspond to
+ * this ID.
+ * @param {object} metadata
+ * The JSON metadata of the parsed install, to be used during
+ * the next startup.
+ */
+ stageAddon(addonId, metadata) {
+ this.staged[addonId] = metadata;
+ XPIStates.save();
+ }
+
+ /**
+ * Removes staged install metadata for the given add-on ID.
+ *
+ * @param {string} addonId
+ * The ID of the staged install.
+ */
+ unstageAddon(addonId) {
+ if (addonId in this.staged) {
+ delete this.staged[addonId];
+ XPIStates.save();
+ }
+ }
+
+ *getStagedAddons() {
+ for (let [id, metadata] of Object.entries(this.staged)) {
+ yield [id, metadata];
+ }
+ }
+
+ /**
+ * Returns true if the given addon was installed in this location by a text
+ * file pointing to its real path.
+ *
+ * @param {string} aId
+ * The ID of the addon
+ * @returns {boolean}
+ */
+ isLinkedAddon(aId) {
+ if (!this.dir) {
+ return true;
+ }
+ return this.has(aId) && !this.dir.contains(this.get(aId).file);
+ }
+
+ get isTemporary() {
+ return false;
+ }
+
+ get isSystem() {
+ return false;
+ }
+
+ get isBuiltin() {
+ return false;
+ }
+
+ get hidden() {
+ return this.isBuiltin;
+ }
+
+ // If this property is false, it does not implement readAddons()
+ // interface. This is used for the temporary and built-in locations
+ // that do not correspond to a physical location that can be scanned.
+ get enumerable() {
+ return true;
+ }
+}
+
+class TemporaryLocation extends XPIStateLocation {
+ /**
+ * @param {string} name
+ * The string identifier for the install location.
+ */
+ constructor(name) {
+ super(name, null, AddonManager.SCOPE_TEMPORARY);
+ this.locked = false;
+ }
+
+ makeInstaller() {
+ // Installs are a no-op. We only register that add-ons exist, and
+ // run them from their current location.
+ return {
+ installAddon() {},
+ uninstallAddon() {},
+ };
+ }
+
+ toJSON() {
+ return {};
+ }
+
+ get isTemporary() {
+ return true;
+ }
+
+ get enumerable() {
+ return false;
+ }
+}
+
+var TemporaryInstallLocation = new TemporaryLocation(KEY_APP_TEMPORARY);
+
+/**
+ * A "location" for addons installed from assets packged into the app.
+ */
+var BuiltInLocation = new (class _BuiltInLocation extends XPIStateLocation {
+ constructor() {
+ super(KEY_APP_BUILTINS, null, AddonManager.SCOPE_APPLICATION);
+ this.locked = false;
+ }
+
+ // The installer object is responsible for moving files around on disk
+ // when (un)installing an addon. Since this location handles only addons
+ // that are embedded within the browser, these are no-ops.
+ makeInstaller() {
+ return {
+ installAddon() {},
+ uninstallAddon() {},
+ };
+ }
+
+ get hidden() {
+ return false;
+ }
+
+ get isBuiltin() {
+ return true;
+ }
+
+ get enumerable() {
+ return false;
+ }
+
+ // Builtin addons are never linked, return false
+ // here for correct behavior elsewhere.
+ isLinkedAddon(/* aId */) {
+ return false;
+ }
+})();
+
+/**
+ * An object which identifies a directory install location for add-ons. The
+ * location consists of a directory which contains the add-ons installed in the
+ * location.
+ *
+ */
+class DirectoryLocation extends XPIStateLocation {
+ /**
+ * Each add-on installed in the location is either a directory containing the
+ * add-on's files or a text file containing an absolute path to the directory
+ * containing the add-ons files. The directory or text file must have the same
+ * name as the add-on's ID.
+ *
+ * @param {string} name
+ * The string identifier for the install location.
+ * @param {nsIFile} dir
+ * The directory for the install location.
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ * @param {boolean} [locked = true]
+ * If false, the location accepts new add-on installs.
+ * @param {boolean} [system = false]
+ * If true, the location is a system addon location.
+ */
+ constructor(name, dir, scope, locked = true, system = false) {
+ super(name, dir, scope);
+ this.locked = locked;
+ this._isSystem = system;
+ }
+
+ makeInstaller() {
+ if (this.locked) {
+ return null;
+ }
+ return new XPIExports.XPIInstall.DirectoryInstaller(this);
+ }
+
+ /**
+ * Reads a single-line file containing the path to a directory, and
+ * returns an nsIFile pointing to that directory, if successful.
+ *
+ * @param {nsIFile} aFile
+ * The file containing the directory path
+ * @returns {nsIFile?}
+ * An nsIFile object representing the linked directory, or null
+ * on error.
+ */
+ _readLinkFile(aFile) {
+ let linkedDirectory;
+ if (aFile.isSymlink()) {
+ linkedDirectory = aFile.clone();
+ try {
+ linkedDirectory.normalize();
+ } catch (e) {
+ logger.warn(
+ `Symbolic link ${aFile.path} points to a path ` +
+ `which does not exist`
+ );
+ return null;
+ }
+ } else {
+ let fis = new FileInputStream(aFile, -1, -1, false);
+ let line = {};
+ fis.QueryInterface(Ci.nsILineInputStream).readLine(line);
+ fis.close();
+
+ if (line.value) {
+ linkedDirectory = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ try {
+ linkedDirectory.initWithPath(line.value);
+ } catch (e) {
+ linkedDirectory.setRelativeDescriptor(aFile.parent, line.value);
+ }
+ }
+ }
+
+ if (linkedDirectory) {
+ if (!linkedDirectory.exists()) {
+ logger.warn(
+ `File pointer ${aFile.path} points to ${linkedDirectory.path} ` +
+ "which does not exist"
+ );
+ return null;
+ }
+
+ if (!linkedDirectory.isDirectory()) {
+ logger.warn(
+ `File pointer ${aFile.path} points to ${linkedDirectory.path} ` +
+ "which is not a directory"
+ );
+ return null;
+ }
+
+ return linkedDirectory;
+ }
+
+ logger.warn(`File pointer ${aFile.path} does not contain a path`);
+ return null;
+ }
+
+ /**
+ * Finds all the add-ons installed in this location.
+ *
+ * @returns {Map<AddonID, nsIFile>}
+ * A map of add-ons present in this location.
+ */
+ readAddons() {
+ let addons = new Map();
+
+ if (!this.dir) {
+ return addons;
+ }
+
+ // Use a snapshot of the directory contents to avoid possible issues with
+ // iterating over a directory while removing files from it (the YAFFS2
+ // embedded filesystem has this issue, see bug 772238).
+ for (let entry of Array.from(iterDirectory(this.dir))) {
+ let id = getExpectedID(entry);
+ if (!id) {
+ if (![DIR_STAGE, DIR_TRASH].includes(entry.leafName)) {
+ logger.debug(
+ "Ignoring file: name is not a valid add-on ID: ${}",
+ entry.path
+ );
+ }
+ continue;
+ }
+
+ if (id == entry.leafName && (entry.isFile() || entry.isSymlink())) {
+ let newEntry = this._readLinkFile(entry);
+ if (!newEntry) {
+ logger.debug(`Deleting stale pointer file ${entry.path}`);
+ try {
+ entry.remove(true);
+ } catch (e) {
+ logger.warn(`Failed to remove stale pointer file ${entry.path}`, e);
+ // Failing to remove the stale pointer file is ignorable
+ }
+ continue;
+ }
+
+ entry = newEntry;
+ }
+
+ addons.set(id, entry);
+ }
+ return addons;
+ }
+
+ get isSystem() {
+ return this._isSystem;
+ }
+}
+
+/**
+ * An object which identifies a built-in install location for add-ons, such
+ * as default system add-ons.
+ *
+ * This location should point either to a XPI, or a directory in a local build.
+ */
+class SystemAddonDefaults extends DirectoryLocation {
+ /**
+ * Read the manifest of allowed add-ons and build a mapping between ID and URI
+ * for each.
+ *
+ * @returns {Map<AddonID, nsIFile>}
+ * A map of add-ons present in this location.
+ */
+ readAddons() {
+ let addons = new Map();
+
+ let manifest = XPIProvider.builtInAddons;
+
+ if (!("system" in manifest)) {
+ logger.debug("No list of valid system add-ons found.");
+ return addons;
+ }
+
+ for (let id of manifest.system) {
+ let file = this.dir.clone();
+ file.append(`${id}.xpi`);
+
+ // Only attempt to load unpacked directory if unofficial build.
+ if (!AppConstants.MOZILLA_OFFICIAL && !file.exists()) {
+ file = this.dir.clone();
+ file.append(`${id}`);
+ }
+
+ addons.set(id, file);
+ }
+
+ return addons;
+ }
+
+ get isSystem() {
+ return true;
+ }
+
+ get isBuiltin() {
+ return true;
+ }
+}
+
+/**
+ * An object which identifies a directory install location for system add-ons
+ * updates.
+ */
+class SystemAddonLocation extends DirectoryLocation {
+ /**
+ * The location consists of a directory which contains the add-ons installed.
+ *
+ * @param {string} name
+ * The string identifier for the install location.
+ * @param {nsIFile} dir
+ * The directory for the install location.
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ * @param {boolean} resetSet
+ * True to throw away the current add-on set
+ */
+ constructor(name, dir, scope, resetSet) {
+ let addonSet = SystemAddonLocation._loadAddonSet();
+ let directory = null;
+
+ // The system add-on update directory is stored in a pref.
+ // Therefore, this is looked up before calling the
+ // constructor on the superclass.
+ if (addonSet.directory) {
+ directory = getFile(addonSet.directory, dir);
+ logger.info(`SystemAddonLocation scanning directory ${directory.path}`);
+ } else {
+ logger.info("SystemAddonLocation directory is missing");
+ }
+
+ super(name, directory, scope, false);
+
+ this._addonSet = addonSet;
+ this._baseDir = dir;
+
+ if (resetSet) {
+ this.installer.resetAddonSet();
+ }
+ }
+
+ makeInstaller() {
+ if (this.locked) {
+ return null;
+ }
+ return new XPIExports.XPIInstall.SystemAddonInstaller(this);
+ }
+
+ /**
+ * Reads the current set of system add-ons
+ *
+ * @returns {Object}
+ */
+ static _loadAddonSet() {
+ try {
+ let setStr = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_SET, null);
+ if (setStr) {
+ let addonSet = JSON.parse(setStr);
+ if (typeof addonSet == "object" && addonSet.schema == 1) {
+ return addonSet;
+ }
+ }
+ } catch (e) {
+ logger.error("Malformed system add-on set, resetting.");
+ }
+
+ return { schema: 1, addons: {} };
+ }
+
+ readAddons() {
+ // Updated system add-ons are ignored in safe mode
+ if (Services.appinfo.inSafeMode) {
+ return new Map();
+ }
+
+ let addons = super.readAddons();
+
+ // Strip out any unexpected add-ons from the list
+ for (let id of addons.keys()) {
+ if (!(id in this._addonSet.addons)) {
+ addons.delete(id);
+ }
+ }
+
+ return addons;
+ }
+
+ /**
+ * Tests whether updated system add-ons are expected.
+ *
+ * @returns {boolean}
+ */
+ isActive() {
+ return this.dir != null;
+ }
+
+ get isSystem() {
+ return true;
+ }
+
+ get isBuiltin() {
+ return true;
+ }
+}
+
+/**
+ * An object that identifies a registry install location for add-ons. The location
+ * consists of a registry key which contains string values mapping ID to the
+ * path where an add-on is installed
+ *
+ */
+class WinRegLocation extends XPIStateLocation {
+ /**
+ * @param {string} name
+ * The string identifier for the install location.
+ * @param {integer} rootKey
+ * The root key (one of the ROOT_KEY_ values from nsIWindowsRegKey).
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ */
+ constructor(name, rootKey, scope) {
+ super(name, undefined, scope);
+
+ this.locked = true;
+ this._rootKey = rootKey;
+ }
+
+ /**
+ * Retrieves the path of this Application's data key in the registry.
+ */
+ get _appKeyPath() {
+ let appVendor = Services.appinfo.vendor;
+ let appName = Services.appinfo.name;
+
+ // XXX Thunderbird doesn't specify a vendor string
+ if (appVendor == "" && AppConstants.MOZ_APP_NAME == "thunderbird") {
+ appVendor = "Mozilla";
+ }
+
+ return `SOFTWARE\\${appVendor}\\${appName}`;
+ }
+
+ /**
+ * Read the registry and build a mapping between ID and path for each
+ * installed add-on.
+ *
+ * @returns {Map<AddonID, nsIFile>}
+ * A map of add-ons in this location.
+ */
+ readAddons() {
+ let addons = new Map();
+
+ let path = `${this._appKeyPath}\\Extensions`;
+ let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+
+ // Reading the registry may throw an exception, and that's ok. In error
+ // cases, we just leave ourselves in the empty state.
+ try {
+ key.open(this._rootKey, path, Ci.nsIWindowsRegKey.ACCESS_READ);
+ } catch (e) {
+ return addons;
+ }
+
+ try {
+ let count = key.valueCount;
+ for (let i = 0; i < count; ++i) {
+ let id = key.getValueName(i);
+ let file = new nsIFile(key.readStringValue(id));
+ if (!file.exists()) {
+ logger.warn(`Ignoring missing add-on in ${file.path}`);
+ continue;
+ }
+
+ addons.set(id, file);
+ }
+ } finally {
+ key.close();
+ }
+
+ return addons;
+ }
+}
+
+/**
+ * Keeps track of the state of XPI add-ons on the file system.
+ */
+var XPIStates = {
+ // Map(location-name -> XPIStateLocation)
+ db: new Map(),
+
+ _jsonFile: null,
+
+ /**
+ * @property {Map<string, XPIState>} sideLoadedAddons
+ * A map of new add-ons detected during install location
+ * directory scans. Keys are add-on IDs, values are XPIState
+ * objects corresponding to those add-ons.
+ */
+ sideLoadedAddons: new Map(),
+
+ get size() {
+ let count = 0;
+ for (let location of this.locations()) {
+ count += location.size;
+ }
+ return count;
+ },
+
+ /**
+ * Load extension state data from addonStartup.json.
+ *
+ * @returns {Object}
+ */
+ loadExtensionState() {
+ let state;
+ try {
+ state = lazy.aomStartup.readStartupData();
+ } catch (e) {
+ logger.warn("Error parsing extensions state: ${error}", { error: e });
+ }
+
+ // When upgrading from a build prior to bug 857456, convert startup
+ // metadata.
+ let done = false;
+ for (let location of Object.values(state || {})) {
+ for (let data of Object.values(location.addons || {})) {
+ if (!migrateAddonLoader(data)) {
+ done = true;
+ break;
+ }
+ }
+ if (done) {
+ break;
+ }
+ }
+
+ logger.debug("Loaded add-on state: ${}", state);
+ return state || {};
+ },
+
+ /**
+ * Walk through all install locations, highest priority first,
+ * comparing the on-disk state of extensions to what is stored in prefs.
+ *
+ * @param {boolean} [ignoreSideloads = true]
+ * If true, ignore changes in scopes where we don't accept
+ * side-loads.
+ *
+ * @returns {boolean}
+ * True if anything has changed.
+ */
+ scanForChanges(ignoreSideloads = true) {
+ let oldState = this.initialStateData || this.loadExtensionState();
+ // We're called twice, do not restore the second time as new data
+ // may have been inserted since the first call.
+ let shouldRestoreLocationData = !this.initialStateData;
+ this.initialStateData = oldState;
+
+ let changed = false;
+ let oldLocations = new Set(Object.keys(oldState));
+
+ let startupScanScopes;
+ if (
+ Services.appinfo.appBuildID ==
+ Services.prefs.getCharPref(PREF_EM_LAST_APP_BUILD_ID, "")
+ ) {
+ startupScanScopes = Services.prefs.getIntPref(
+ PREF_EM_STARTUP_SCAN_SCOPES,
+ 0
+ );
+ } else {
+ // If the build id has changed, we need to do a full scan on first startup.
+ Services.prefs.setCharPref(
+ PREF_EM_LAST_APP_BUILD_ID,
+ Services.appinfo.appBuildID
+ );
+ startupScanScopes = AddonManager.SCOPE_ALL;
+ }
+
+ for (let loc of XPIStates.locations()) {
+ oldLocations.delete(loc.name);
+
+ if (shouldRestoreLocationData && oldState[loc.name]) {
+ loc.restore(oldState[loc.name]);
+ }
+ changed = changed || loc.changed;
+
+ // Don't bother checking scopes where we don't accept side-loads.
+ if (ignoreSideloads && !(loc.scope & startupScanScopes)) {
+ continue;
+ }
+
+ if (!loc.enumerable) {
+ continue;
+ }
+
+ // Don't bother scanning scopes where we don't have addons installed if they
+ // do not allow sideloading new addons. Once we have an addon in one of those
+ // locations, we need to check the location for changes (updates/deletions).
+ if (!loc.size && !(loc.scope & lazy.AddonSettings.SCOPES_SIDELOAD)) {
+ continue;
+ }
+
+ let knownIds = new Set(loc.keys());
+ for (let [id, file] of loc.readAddons()) {
+ knownIds.delete(id);
+
+ let xpiState = loc.get(id);
+ if (!xpiState) {
+ // If the location is not supported for sideloading, skip new
+ // addons. We handle this here so changes for existing sideloads
+ // will function.
+ if (
+ !loc.isSystem &&
+ !(loc.scope & lazy.AddonSettings.SCOPES_SIDELOAD)
+ ) {
+ continue;
+ }
+ logger.debug("New add-on ${id} in ${loc}", { id, loc: loc.name });
+
+ changed = true;
+ xpiState = loc.addFile(id, file);
+ if (!loc.isSystem) {
+ this.sideLoadedAddons.set(id, xpiState);
+ }
+ } else {
+ let addonChanged =
+ xpiState.getModTime(file) || file.path != xpiState.path;
+ xpiState.file = file.clone();
+
+ if (addonChanged) {
+ changed = true;
+ logger.debug("Changed add-on ${id} in ${loc}", {
+ id,
+ loc: loc.name,
+ });
+ } else {
+ logger.debug("Existing add-on ${id} in ${loc}", {
+ id,
+ loc: loc.name,
+ });
+ }
+ }
+ XPIProvider.addTelemetry(id, { location: loc.name });
+ }
+
+ // Anything left behind in oldState was removed from the file system.
+ for (let id of knownIds) {
+ loc.delete(id);
+ changed = true;
+ }
+ }
+
+ // If there's anything left in oldState, an install location that held add-ons
+ // was removed from the browser configuration.
+ changed = changed || oldLocations.size > 0;
+
+ logger.debug("scanForChanges changed: ${rv}, state: ${state}", {
+ rv: changed,
+ state: this.db,
+ });
+ return changed;
+ },
+
+ locations() {
+ return this.db.values();
+ },
+
+ /**
+ * @param {string} name
+ * The location name.
+ * @param {XPIStateLocation} location
+ * The location object.
+ */
+ addLocation(name, location) {
+ if (this.db.has(name)) {
+ throw new Error(`Trying to add duplicate location: ${name}`);
+ }
+ this.db.set(name, location);
+ },
+
+ /**
+ * Get the Map of XPI states for a particular location.
+ *
+ * @param {string} name
+ * The name of the install location.
+ *
+ * @returns {XPIStateLocation?}
+ * (id -> XPIState) or null if there are no add-ons in the location.
+ */
+ getLocation(name) {
+ return this.db.get(name);
+ },
+
+ /**
+ * Get the XPI state for a specific add-on in a location.
+ * If the state is not in our cache, return null.
+ *
+ * @param {string} aLocation
+ * The name of the location where the add-on is installed.
+ * @param {string} aId
+ * The add-on ID
+ *
+ * @returns {XPIState?}
+ * The XPIState entry for the add-on, or null.
+ */
+ getAddon(aLocation, aId) {
+ let location = this.db.get(aLocation);
+ return location && location.get(aId);
+ },
+
+ /**
+ * Find the highest priority location of an add-on by ID and return the
+ * XPIState.
+ * @param {string} aId
+ * The add-on IDa
+ * @param {function} aFilter
+ * An optional filter to apply to install locations. If provided,
+ * addons in locations that do not match the filter are not considered.
+ *
+ * @returns {XPIState?}
+ */
+ findAddon(aId, aFilter = location => true) {
+ // Fortunately the Map iterator returns in order of insertion, which is
+ // also our highest -> lowest priority order.
+ for (let location of this.locations()) {
+ if (!aFilter(location)) {
+ continue;
+ }
+ if (location.has(aId)) {
+ return location.get(aId);
+ }
+ }
+ return undefined;
+ },
+
+ /**
+ * Iterates over the list of all enabled add-ons in any location.
+ */
+ *enabledAddons() {
+ for (let location of this.locations()) {
+ for (let entry of location.values()) {
+ if (entry.enabled) {
+ yield entry;
+ }
+ }
+ }
+ },
+
+ /**
+ * Add a new XPIState for an add-on and synchronize it with the DBAddonInternal.
+ *
+ * @param {DBAddonInternal} aAddon
+ * The add-on to add.
+ */
+ addAddon(aAddon) {
+ aAddon.location.addAddon(aAddon);
+ },
+
+ /**
+ * Save the current state of installed add-ons.
+ */
+ save() {
+ if (!this._jsonFile) {
+ this._jsonFile = new lazy.JSONFile({
+ path: PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ FILE_XPI_STATES
+ ),
+ finalizeAt: AddonManagerPrivate.finalShutdown,
+ compression: "lz4",
+ });
+ this._jsonFile.data = this;
+ }
+
+ this._jsonFile.saveSoon();
+ },
+
+ toJSON() {
+ let data = {};
+ for (let [key, loc] of this.db.entries()) {
+ if (!loc.isTemporary && (loc.size || loc.hasStaged)) {
+ data[key] = loc;
+ }
+ }
+ return data;
+ },
+
+ /**
+ * Remove the XPIState for an add-on and save the new state.
+ *
+ * @param {string} aLocation
+ * The name of the add-on location.
+ * @param {string} aId
+ * The ID of the add-on.
+ *
+ */
+ removeAddon(aLocation, aId) {
+ logger.debug(`Removing XPIState for ${aLocation}: ${aId}`);
+ let location = this.db.get(aLocation);
+ if (location) {
+ location.removeAddon(aId);
+ this.save();
+ }
+ },
+
+ /**
+ * Disable the XPIState for an add-on.
+ *
+ * @param {string} aId
+ * The ID of the add-on.
+ */
+ disableAddon(aId) {
+ logger.debug(`Disabling XPIState for ${aId}`);
+ let state = this.findAddon(aId);
+ if (state) {
+ state.enabled = false;
+ }
+ },
+};
+
+/**
+ * A helper class to manage the lifetime of and interaction with
+ * bootstrap scopes for an add-on.
+ *
+ * @param {Object} addon
+ * The add-on which owns this scope. Should be either an
+ * AddonInternal or XPIState object.
+ */
+class BootstrapScope {
+ constructor(addon) {
+ if (!addon.id || !addon.version || !addon.type) {
+ throw new Error("Addon must include an id, version, and type");
+ }
+
+ this.addon = addon;
+ this.instanceID = null;
+ this.scope = null;
+ this.started = false;
+ }
+
+ /**
+ * Returns a BootstrapScope object for the given add-on. If an active
+ * scope exists, it is returned. Otherwise a new one is created.
+ *
+ * @param {Object} addon
+ * The add-on which owns this scope, as accepted by the
+ * constructor.
+ * @returns {BootstrapScope}
+ */
+ static get(addon) {
+ let scope = XPIProvider.activeAddons.get(addon.id);
+ if (!scope) {
+ scope = new this(addon);
+ }
+ return scope;
+ }
+
+ get file() {
+ return this.addon.file || this.addon._sourceBundle;
+ }
+
+ get runInSafeMode() {
+ return "runInSafeMode" in this.addon
+ ? this.addon.runInSafeMode
+ : canRunInSafeMode(this.addon);
+ }
+
+ /**
+ * Returns state information for use by an AsyncShutdown blocker. If
+ * the wrapped bootstrap scope has a fetchState method, it is called,
+ * and its result returned. If not, returns null.
+ *
+ * @returns {Object|null}
+ */
+ fetchState() {
+ if (this.scope && this.scope.fetchState) {
+ return this.scope.fetchState();
+ }
+ return null;
+ }
+
+ /**
+ * Calls a bootstrap method for an add-on.
+ *
+ * @param {string} aMethod
+ * The name of the bootstrap method to call
+ * @param {integer} aReason
+ * The reason flag to pass to the bootstrap's startup method
+ * @param {Object} [aExtraParams = {}]
+ * An object of additional key/value pairs to pass to the method in
+ * the params argument
+ * @returns {any}
+ * The return value of the bootstrap method.
+ */
+ async callBootstrapMethod(aMethod, aReason, aExtraParams = {}) {
+ let { addon, runInSafeMode } = this;
+ if (
+ Services.appinfo.inSafeMode &&
+ !runInSafeMode &&
+ aMethod !== "uninstall"
+ ) {
+ return null;
+ }
+
+ try {
+ if (!this.scope) {
+ this.loadBootstrapScope(aReason);
+ }
+
+ if (aMethod == "startup" || aMethod == "shutdown") {
+ aExtraParams.instanceID = this.instanceID;
+ }
+
+ let method = undefined;
+ let { scope } = this;
+ try {
+ method = scope[aMethod];
+ } catch (e) {
+ // An exception will be caught if the expected method is not defined.
+ // That will be logged below.
+ }
+
+ if (aMethod == "startup") {
+ this.started = true;
+ } else if (aMethod == "shutdown") {
+ this.started = false;
+
+ // Extensions are automatically deinitialized in the correct order at shutdown.
+ if (aReason != BOOTSTRAP_REASONS.APP_SHUTDOWN) {
+ this._pendingDisable = true;
+ for (let addon of XPIProvider.getDependentAddons(this.addon)) {
+ if (addon.active) {
+ await XPIExports.XPIDatabase.updateAddonDisabledState(addon);
+ }
+ }
+ }
+ }
+
+ let params = {
+ id: addon.id,
+ version: addon.version,
+ resourceURI: addon.resolvedRootURI,
+ signedState: addon.signedState,
+ temporarilyInstalled: addon.location.isTemporary,
+ builtIn: addon.location.isBuiltin,
+ isSystem: addon.location.isSystem,
+ isPrivileged: addon.isPrivileged,
+ locationHidden: addon.location.hidden,
+ recommendationState: addon.recommendationState,
+ };
+
+ if (aMethod == "startup" && addon.startupData) {
+ params.startupData = addon.startupData;
+ }
+
+ Object.assign(params, aExtraParams);
+
+ let result;
+ if (!method) {
+ logger.warn(
+ `Add-on ${addon.id} is missing bootstrap method ${aMethod}`
+ );
+ } else {
+ logger.debug(
+ `Calling bootstrap method ${aMethod} on ${addon.id} version ${addon.version}`
+ );
+
+ this._beforeCallBootstrapMethod(aMethod, params, aReason);
+
+ try {
+ result = await method.call(scope, params, aReason);
+ } catch (e) {
+ logger.warn(
+ `Exception running bootstrap method ${aMethod} on ${addon.id}`,
+ e
+ );
+ }
+ }
+ return result;
+ } finally {
+ // Extensions are automatically initialized in the correct order at startup.
+ if (aMethod == "startup" && aReason != BOOTSTRAP_REASONS.APP_STARTUP) {
+ for (let addon of XPIProvider.getDependentAddons(this.addon)) {
+ XPIExports.XPIDatabase.updateAddonDisabledState(addon);
+ }
+ }
+ }
+ }
+
+ // No-op method to be overridden by tests.
+ _beforeCallBootstrapMethod() {}
+
+ /**
+ * Loads a bootstrapped add-on's bootstrap.js into a sandbox and the reason
+ * values as constants in the scope.
+ *
+ * @param {integer?} [aReason]
+ * The reason this bootstrap is being loaded, as passed to a
+ * bootstrap method.
+ */
+ loadBootstrapScope(aReason) {
+ this.instanceID = Symbol(this.addon.id);
+ this._pendingDisable = false;
+
+ XPIProvider.activeAddons.set(this.addon.id, this);
+
+ // Mark the add-on as active for the crash reporter before loading.
+ // But not at app startup, since we'll already have added all of our
+ // annotations before starting any loads.
+ if (aReason !== BOOTSTRAP_REASONS.APP_STARTUP) {
+ XPIProvider.addAddonsToCrashReporter();
+ }
+
+ logger.debug(`Loading bootstrap scope from ${this.addon.rootURI}`);
+
+ if (this.addon.isWebExtension) {
+ switch (this.addon.type) {
+ case "extension":
+ case "theme":
+ this.scope = lazy.Extension.getBootstrapScope();
+ break;
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ case "sitepermission-deprecated":
+ this.scope = lazy.SitePermission.getBootstrapScope();
+ break;
+
+ case "locale":
+ this.scope = lazy.Langpack.getBootstrapScope();
+ break;
+
+ case "dictionary":
+ this.scope = lazy.Dictionary.getBootstrapScope();
+ break;
+
+ default:
+ throw new Error(`Unknown webextension type ${this.addon.type}`);
+ }
+ } else {
+ let loader = AddonManagerPrivate.externalExtensionLoaders.get(
+ this.addon.loader
+ );
+ if (!loader) {
+ throw new Error(`Cannot find loader for ${this.addon.loader}`);
+ }
+
+ this.scope = loader.loadScope(this.addon);
+ }
+ }
+
+ /**
+ * Unloads a bootstrap scope by dropping all references to it and then
+ * updating the list of active add-ons with the crash reporter.
+ */
+ unloadBootstrapScope() {
+ XPIProvider.activeAddons.delete(this.addon.id);
+ XPIProvider.addAddonsToCrashReporter();
+
+ this.scope = null;
+ this.startupPromise = null;
+ this.instanceID = null;
+ }
+
+ /**
+ * Calls the bootstrap scope's startup method, with the given reason
+ * and extra parameters.
+ *
+ * @param {integer} reason
+ * The reason code for the startup call.
+ * @param {Object} [aExtraParams]
+ * Optional extra parameters to pass to the bootstrap method.
+ * @returns {Promise}
+ * Resolves when the startup method has run to completion, rejects
+ * if called late during shutdown.
+ */
+ async startup(reason, aExtraParams) {
+ if (this.shutdownPromise) {
+ await this.shutdownPromise;
+ }
+
+ if (
+ Services.startup.isInOrBeyondShutdownPhase(
+ Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ )
+ ) {
+ let err = new Error(
+ `XPIProvider can't start bootstrap scope for ${this.addon.id} after shutdown was already granted`
+ );
+ logger.warn("BoostrapScope startup failure: ${error}", { error: err });
+ this.startupPromise = Promise.reject(err);
+ } else {
+ this.startupPromise = this.callBootstrapMethod(
+ "startup",
+ reason,
+ aExtraParams
+ );
+ }
+
+ return this.startupPromise;
+ }
+
+ /**
+ * Calls the bootstrap scope's shutdown method, with the given reason
+ * and extra parameters.
+ *
+ * @param {integer} reason
+ * The reason code for the shutdown call.
+ * @param {Object} [aExtraParams]
+ * Optional extra parameters to pass to the bootstrap method.
+ */
+ async shutdown(reason, aExtraParams) {
+ this.shutdownPromise = this._shutdown(reason, aExtraParams);
+ await this.shutdownPromise;
+ this.shutdownPromise = null;
+ }
+
+ async _shutdown(reason, aExtraParams) {
+ await this.startupPromise;
+ return this.callBootstrapMethod("shutdown", reason, aExtraParams);
+ }
+
+ /**
+ * If the add-on is already running, calls its "shutdown" method, and
+ * unloads its bootstrap scope.
+ *
+ * @param {integer} reason
+ * The reason code for the shutdown call.
+ * @param {Object} [aExtraParams]
+ * Optional extra parameters to pass to the bootstrap method.
+ */
+ async disable() {
+ if (this.started) {
+ await this.shutdown(BOOTSTRAP_REASONS.ADDON_DISABLE);
+ // If we disable and re-enable very quickly, it's possible that
+ // the next startup() method will be called immediately after this
+ // shutdown method finishes. This almost never happens outside of
+ // tests. In tests, alas...
+ if (!this.started) {
+ this.unloadBootstrapScope();
+ }
+ }
+ }
+
+ /**
+ * Calls the bootstrap scope's install method, and optionally its
+ * startup method.
+ *
+ * @param {integer} reason
+ * The reason code for the calls.
+ * @param {boolean} [startup = false]
+ * If true, and the add-on is active, calls its startup method
+ * after its install method.
+ * @param {Object} [extraArgs]
+ * Optional extra parameters to pass to the bootstrap method.
+ * @returns {Promise}
+ * Resolves when the startup method has run to completion, if
+ * startup is required.
+ */
+ install(reason = BOOTSTRAP_REASONS.ADDON_INSTALL, startup, extraArgs) {
+ return this._install(reason, false, startup, extraArgs);
+ }
+
+ async _install(reason, callUpdate, startup, extraArgs) {
+ if (callUpdate) {
+ await this.callBootstrapMethod("update", reason, extraArgs);
+ } else {
+ this.callBootstrapMethod("install", reason, extraArgs);
+ }
+
+ if (startup && this.addon.active) {
+ await this.startup(reason, extraArgs);
+ } else if (this.addon.disabled) {
+ this.unloadBootstrapScope();
+ }
+ }
+
+ /**
+ * Calls the bootstrap scope's uninstall method, and unloads its
+ * bootstrap scope. If the extension is already running, its shutdown
+ * method is called before its uninstall method.
+ *
+ * @param {integer} reason
+ * The reason code for the calls.
+ * @param {Object} [extraArgs]
+ * Optional extra parameters to pass to the bootstrap method.
+ * @returns {Promise}
+ * Resolves when the shutdown method has run to completion, if
+ * shutdown is required, and the uninstall method has been
+ * called.
+ */
+ uninstall(reason = BOOTSTRAP_REASONS.ADDON_UNINSTALL, extraArgs) {
+ return this._uninstall(reason, false, extraArgs);
+ }
+
+ async _uninstall(reason, callUpdate, extraArgs) {
+ if (this.started) {
+ await this.shutdown(reason, extraArgs);
+ }
+ if (!callUpdate) {
+ this.callBootstrapMethod("uninstall", reason, extraArgs);
+ }
+ this.unloadBootstrapScope();
+
+ if (this.file) {
+ XPIExports.XPIInstall.flushJarCache(this.file);
+ }
+ }
+
+ /**
+ * Calls the appropriate sequence of shutdown, uninstall, update,
+ * startup, and install methods for updating the current scope's
+ * add-on to the given new add-on, depending on the current state of
+ * the scope.
+ *
+ * @param {XPIState} newAddon
+ * The new add-on which is being installed, as expected by the
+ * constructor.
+ * @param {boolean} [startup = false]
+ * If true, and the new add-on is enabled, calls its startup
+ * method as its final operation.
+ * @param {function} [updateCallback]
+ * An optional callback function to call between uninstalling
+ * the old add-on and installing the new one. This callback
+ * should update any database state which is necessary for the
+ * startup of the new add-on.
+ * @returns {Promise}
+ * Resolves when all required bootstrap callbacks have
+ * completed.
+ */
+ async update(newAddon, startup = false, updateCallback) {
+ let reason = XPIExports.XPIInstall.newVersionReason(
+ this.addon.version,
+ newAddon.version
+ );
+
+ let callUpdate = this.addon.isWebExtension && newAddon.isWebExtension;
+
+ // BootstrapScope gets either an XPIState instance or an AddonInternal
+ // instance, when we update, we need the latter to access permissions
+ // from the manifest.
+ let existingAddon = this.addon;
+
+ let extraArgs = {
+ oldVersion: existingAddon.version,
+ newVersion: newAddon.version,
+ };
+
+ // If we're updating an extension, we may need to read data to
+ // calculate permission changes.
+ if (callUpdate && existingAddon.type === "extension") {
+ if (this.addon instanceof XPIState) {
+ // The existing addon will be cached in the database.
+ existingAddon = await XPIExports.XPIDatabase.getAddonByID(
+ this.addon.id
+ );
+ }
+
+ if (newAddon instanceof XPIState) {
+ newAddon = await XPIExports.XPIInstall.loadManifestFromFile(
+ newAddon.file,
+ newAddon.location
+ );
+ }
+
+ Object.assign(extraArgs, {
+ userPermissions: newAddon.userPermissions,
+ optionalPermissions: newAddon.optionalPermissions,
+ oldPermissions: existingAddon.userPermissions,
+ oldOptionalPermissions: existingAddon.optionalPermissions,
+ });
+ }
+
+ await this._uninstall(reason, callUpdate, extraArgs);
+
+ if (updateCallback) {
+ await updateCallback();
+ }
+
+ this.addon = newAddon;
+ return this._install(reason, callUpdate, startup, extraArgs);
+ }
+}
+
+let resolveDBReady;
+let dbReadyPromise = new Promise(resolve => {
+ resolveDBReady = resolve;
+});
+let resolveProviderReady;
+let providerReadyPromise = new Promise(resolve => {
+ resolveProviderReady = resolve;
+});
+
+export var XPIProvider = {
+ get name() {
+ return "XPIProvider";
+ },
+
+ BOOTSTRAP_REASONS: Object.freeze(BOOTSTRAP_REASONS),
+
+ // A Map of active addons to their bootstrapScope by ID
+ activeAddons: new Map(),
+ // Per-addon telemetry information
+ _telemetryDetails: {},
+ // Have we started shutting down bootstrap add-ons?
+ _closing: false,
+
+ // Promises awaited by the XPIProvider before resolving providerReadyPromise,
+ // (pushed into the array by XPIProvider maybeInstallBuiltinAddon and startup
+ // methods).
+ startupPromises: [],
+
+ // Array of the bootstrap startup promises for the enabled addons being
+ // initiated during the XPIProvider startup.
+ //
+ // NOTE: XPIProvider will wait for these promises (and the startupPromises one)
+ // to have settled before allowing the application to proceed with shutting down
+ // (see quitApplicationGranted blocker at the end of the XPIProvider.startup).
+ enabledAddonsStartupPromises: [],
+
+ databaseReady: Promise.all([dbReadyPromise, providerReadyPromise]),
+
+ registerProvider() {
+ AddonManagerPrivate.registerProvider(this, Array.from(ALL_XPI_TYPES));
+ },
+
+ // Check if the XPIDatabase has been loaded (without actually
+ // triggering unwanted imports or I/O)
+ get isDBLoaded() {
+ // Make sure we don't touch the XPIDatabase getter before it's
+ // actually loaded, and force an early load.
+ return (
+ (Object.getOwnPropertyDescriptor(XPIExports, "XPIDatabase").value &&
+ XPIExports.XPIDatabase.initialized) ||
+ false
+ );
+ },
+
+ /**
+ * Returns true if the add-on with the given ID is currently active,
+ * without forcing the add-ons database to load.
+ *
+ * @param {string} addonId
+ * The ID of the add-on to check.
+ * @returns {boolean}
+ */
+ addonIsActive(addonId) {
+ let state = XPIStates.findAddon(addonId);
+ return state && state.enabled;
+ },
+
+ /**
+ * Returns an array of the add-on values in `enabledAddons`,
+ * sorted so that all of an add-on's dependencies appear in the array
+ * before itself.
+ *
+ * @returns {Array<object>}
+ * A sorted array of add-on objects. Each value is a copy of the
+ * corresponding value in the `enabledAddons` object, with an
+ * additional `id` property, which corresponds to the key in that
+ * object, which is the same as the add-ons ID.
+ */
+ sortBootstrappedAddons() {
+ function compare(a, b) {
+ if (a === b) {
+ return 0;
+ }
+ return a < b ? -1 : 1;
+ }
+
+ // Sort the list so that ordering is deterministic.
+ let list = Array.from(XPIStates.enabledAddons());
+ list.sort((a, b) => compare(a.id, b.id));
+
+ let addons = {};
+ for (let entry of list) {
+ addons[entry.id] = entry;
+ }
+
+ let res = new Set();
+ let seen = new Set();
+
+ let add = addon => {
+ seen.add(addon.id);
+
+ for (let id of addon.dependencies || []) {
+ if (id in addons && !seen.has(id)) {
+ add(addons[id]);
+ }
+ }
+
+ res.add(addon.id);
+ };
+
+ Object.values(addons).forEach(add);
+
+ return Array.from(res, id => addons[id]);
+ },
+
+ /*
+ * Adds metadata to the telemetry payload for the given add-on.
+ */
+ addTelemetry(aId, aPayload) {
+ if (!this._telemetryDetails[aId]) {
+ this._telemetryDetails[aId] = {};
+ }
+ Object.assign(this._telemetryDetails[aId], aPayload);
+ },
+
+ setupInstallLocations(aAppChanged) {
+ function DirectoryLoc(aName, aScope, aKey, aPaths, aLocked, aIsSystem) {
+ try {
+ var dir = lazy.FileUtils.getDir(aKey, aPaths);
+ } catch (e) {
+ return null;
+ }
+ return new DirectoryLocation(aName, dir, aScope, aLocked, aIsSystem);
+ }
+
+ function SystemDefaultsLoc(name, scope, key, paths) {
+ try {
+ var dir = lazy.FileUtils.getDir(key, paths);
+ } catch (e) {
+ return null;
+ }
+ return new SystemAddonDefaults(name, dir, scope);
+ }
+
+ function SystemLoc(aName, aScope, aKey, aPaths) {
+ try {
+ var dir = lazy.FileUtils.getDir(aKey, aPaths);
+ } catch (e) {
+ return null;
+ }
+ return new SystemAddonLocation(aName, dir, aScope, aAppChanged !== false);
+ }
+
+ function RegistryLoc(aName, aScope, aKey) {
+ if ("nsIWindowsRegKey" in Ci) {
+ return new WinRegLocation(aName, Ci.nsIWindowsRegKey[aKey], aScope);
+ }
+ }
+
+ // These must be in order of priority, highest to lowest,
+ // for processFileChanges etc. to work
+ let locations = [
+ [() => TemporaryInstallLocation, TemporaryInstallLocation.name, null],
+
+ [
+ DirectoryLoc,
+ KEY_APP_PROFILE,
+ AddonManager.SCOPE_PROFILE,
+ KEY_PROFILEDIR,
+ [DIR_EXTENSIONS],
+ false,
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_PROFILE,
+ AddonManager.SCOPE_APPLICATION,
+ KEY_PROFILEDIR,
+ [DIR_APP_SYSTEM_PROFILE],
+ false,
+ true,
+ ],
+
+ [
+ SystemLoc,
+ KEY_APP_SYSTEM_ADDONS,
+ AddonManager.SCOPE_PROFILE,
+ KEY_PROFILEDIR,
+ [DIR_SYSTEM_ADDONS],
+ ],
+
+ [
+ SystemDefaultsLoc,
+ KEY_APP_SYSTEM_DEFAULTS,
+ AddonManager.SCOPE_PROFILE,
+ KEY_APP_FEATURES,
+ [],
+ ],
+
+ [() => BuiltInLocation, KEY_APP_BUILTINS, AddonManager.SCOPE_APPLICATION],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_USER,
+ AddonManager.SCOPE_USER,
+ "XREUSysExt",
+ [Services.appinfo.ID],
+ true,
+ ],
+
+ [
+ RegistryLoc,
+ "winreg-app-user",
+ AddonManager.SCOPE_USER,
+ "ROOT_KEY_CURRENT_USER",
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_GLOBAL,
+ AddonManager.SCOPE_APPLICATION,
+ KEY_ADDON_APP_DIR,
+ [DIR_EXTENSIONS],
+ true,
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_SHARE,
+ AddonManager.SCOPE_SYSTEM,
+ "XRESysSExtPD",
+ [Services.appinfo.ID],
+ true,
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_LOCAL,
+ AddonManager.SCOPE_SYSTEM,
+ "XRESysLExtPD",
+ [Services.appinfo.ID],
+ true,
+ ],
+
+ [
+ RegistryLoc,
+ "winreg-app-global",
+ AddonManager.SCOPE_SYSTEM,
+ "ROOT_KEY_LOCAL_MACHINE",
+ ],
+ ];
+
+ for (let [constructor, name, scope, ...args] of locations) {
+ if (!scope || lazy.enabledScopes & scope) {
+ try {
+ let loc = constructor(name, scope, ...args);
+ if (loc) {
+ XPIStates.addLocation(name, loc);
+ }
+ } catch (e) {
+ logger.warn(
+ `Failed to add ${constructor.name} install location ${name}`,
+ e
+ );
+ }
+ }
+ }
+ },
+
+ /**
+ * Registers the built-in set of dictionaries with the spell check
+ * service.
+ */
+ registerBuiltinDictionaries() {
+ this.dictionaries = {};
+ for (let [lang, path] of Object.entries(
+ this.builtInAddons.dictionaries || {}
+ )) {
+ path = path.slice(0, -4) + ".aff";
+ let uri = Services.io.newURI(`resource://gre/${path}`);
+
+ this.dictionaries[lang] = uri;
+ lazy.spellCheck.addDictionary(lang, uri);
+ }
+ },
+
+ /**
+ * Unregisters the dictionaries in the given object, and re-registers
+ * any built-in dictionaries in their place, when they exist.
+ *
+ * @param {Object<nsIURI>} aDicts
+ * An object containing a property with a dictionary language
+ * code and a nsIURI value for each dictionary to be
+ * unregistered.
+ */
+ unregisterDictionaries(aDicts) {
+ let origDicts = lazy.spellCheck.dictionaries.slice();
+ let toRemove = [];
+
+ for (let [lang, uri] of Object.entries(aDicts)) {
+ if (
+ lazy.spellCheck.removeDictionary(lang, uri) &&
+ this.dictionaries.hasOwnProperty(lang)
+ ) {
+ lazy.spellCheck.addDictionary(lang, this.dictionaries[lang]);
+ } else {
+ toRemove.push(lang);
+ }
+ }
+
+ lazy.spellCheck.dictionaries = origDicts.filter(
+ lang => !toRemove.includes(lang)
+ );
+ },
+
+ /**
+ * Starts the XPI provider initializes the install locations and prefs.
+ *
+ * @param {boolean?} aAppChanged
+ * A tri-state value. Undefined means the current profile was created
+ * for this session, true means the profile already existed but was
+ * last used with an application with a different version number,
+ * false means that the profile was last used by this version of the
+ * application.
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ */
+ startup(aAppChanged, aOldAppVersion, aOldPlatformVersion) {
+ try {
+ AddonManagerPrivate.recordTimestamp("XPI_startup_begin");
+
+ logger.debug("startup");
+
+ this.builtInAddons = {};
+ try {
+ let url = Services.io.newURI(BUILT_IN_ADDONS_URI);
+ let data = Cu.readUTF8URI(url);
+ this.builtInAddons = JSON.parse(data);
+ } catch (e) {
+ if (AppConstants.DEBUG) {
+ logger.debug("List of built-in add-ons is missing or invalid.", e);
+ }
+ }
+
+ this.registerBuiltinDictionaries();
+
+ // Clear this at startup for xpcshell test restarts
+ this._telemetryDetails = {};
+ // Register our details structure with AddonManager
+ AddonManagerPrivate.setTelemetryDetails("XPI", this._telemetryDetails);
+
+ this.setupInstallLocations(aAppChanged);
+
+ if (!AppConstants.MOZ_REQUIRE_SIGNING || Cu.isInAutomation) {
+ Services.prefs.addObserver(PREF_XPI_SIGNATURES_REQUIRED, this);
+ }
+ Services.prefs.addObserver(PREF_LANGPACK_SIGNATURES, this);
+ Services.obs.addObserver(this, NOTIFICATION_FLUSH_PERMISSIONS);
+
+ this.checkForChanges(aAppChanged, aOldAppVersion, aOldPlatformVersion);
+
+ AddonManagerPrivate.markProviderSafe(this);
+
+ const lastTheme = Services.prefs.getCharPref(
+ "extensions.activeThemeID",
+ null
+ );
+
+ if (
+ lastTheme === "recommended-1" ||
+ lastTheme === "recommended-2" ||
+ lastTheme === "recommended-3" ||
+ lastTheme === "recommended-4" ||
+ lastTheme === "recommended-5"
+ ) {
+ // The user is using a theme that was once bundled with Firefox, but no longer
+ // is. Clear their theme so that they will be forced to reset to the default.
+ this.startupPromises.push(
+ AddonManagerPrivate.notifyAddonChanged(null, "theme")
+ );
+ }
+ this.maybeInstallBuiltinAddon(
+ "default-theme@mozilla.org",
+ "1.3",
+ "resource://default-theme/"
+ );
+
+ resolveProviderReady(Promise.all(this.startupPromises));
+
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ // Annotate the crash report with relevant add-on information.
+ try {
+ // The `EMCheckCompatibility` annotation represents a boolean, but
+ // we've historically set it as a string so keep doing it for the
+ // time being.
+ Services.appinfo.annotateCrashReport(
+ "EMCheckCompatibility",
+ AddonManager.checkCompatibility.toString()
+ );
+ } catch (e) {}
+ this.addAddonsToCrashReporter();
+ }
+
+ try {
+ AddonManagerPrivate.recordTimestamp("XPI_bootstrap_addons_begin");
+
+ for (let addon of this.sortBootstrappedAddons()) {
+ // The startup update check above may have already started some
+ // extensions, make sure not to try to start them twice.
+ let activeAddon = this.activeAddons.get(addon.id);
+ if (activeAddon && activeAddon.started) {
+ continue;
+ }
+ try {
+ let reason = BOOTSTRAP_REASONS.APP_STARTUP;
+ // Eventually set INSTALLED reason when a bootstrap addon
+ // is dropped in profile folder and automatically installed
+ if (
+ AddonManager.getStartupChanges(
+ AddonManager.STARTUP_CHANGE_INSTALLED
+ ).includes(addon.id)
+ ) {
+ reason = BOOTSTRAP_REASONS.ADDON_INSTALL;
+ } else if (
+ AddonManager.getStartupChanges(
+ AddonManager.STARTUP_CHANGE_ENABLED
+ ).includes(addon.id)
+ ) {
+ reason = BOOTSTRAP_REASONS.ADDON_ENABLE;
+ }
+ this.enabledAddonsStartupPromises.push(
+ BootstrapScope.get(addon).startup(reason)
+ );
+ } catch (e) {
+ logger.error(
+ "Failed to load bootstrap addon " +
+ addon.id +
+ " from " +
+ addon.descriptor,
+ e
+ );
+ }
+ }
+ AddonManagerPrivate.recordTimestamp("XPI_bootstrap_addons_end");
+ } catch (e) {
+ logger.error("bootstrap startup failed", e);
+ AddonManagerPrivate.recordException(
+ "XPI-BOOTSTRAP",
+ "startup failed",
+ e
+ );
+ }
+
+ // Let these shutdown a little earlier when they still have access to most
+ // of XPCOM
+ lazy.AsyncShutdown.quitApplicationGranted.addBlocker(
+ "XPIProvider shutdown",
+ async () => {
+ // Do not enter shutdown before we actually finished starting as this
+ // can lead to hangs as seen in bug 1814104.
+ await Promise.allSettled([
+ ...this.startupPromises,
+ ...this.enabledAddonsStartupPromises,
+ ]);
+
+ XPIProvider._closing = true;
+
+ await XPIProvider.cleanupTemporaryAddons();
+ for (let addon of XPIProvider.sortBootstrappedAddons().reverse()) {
+ // If no scope has been loaded for this add-on then there is no need
+ // to shut it down (should only happen when a bootstrapped add-on is
+ // pending enable)
+ let activeAddon = XPIProvider.activeAddons.get(addon.id);
+ if (!activeAddon || !activeAddon.started) {
+ continue;
+ }
+
+ // If the add-on was pending disable then shut it down and remove it
+ // from the persisted data.
+ let reason = BOOTSTRAP_REASONS.APP_SHUTDOWN;
+ if (addon._pendingDisable) {
+ reason = BOOTSTRAP_REASONS.ADDON_DISABLE;
+ } else if (addon.location.name == KEY_APP_TEMPORARY) {
+ reason = BOOTSTRAP_REASONS.ADDON_UNINSTALL;
+ let existing = XPIStates.findAddon(
+ addon.id,
+ loc => !loc.isTemporary
+ );
+ if (existing) {
+ reason = XPIExports.XPIInstall.newVersionReason(
+ addon.version,
+ existing.version
+ );
+ }
+ }
+
+ let scope = BootstrapScope.get(addon);
+ let promise = scope.shutdown(reason);
+ lazy.AsyncShutdown.profileChangeTeardown.addBlocker(
+ `Extension shutdown: ${addon.id}`,
+ promise,
+ {
+ fetchState: scope.fetchState.bind(scope),
+ }
+ );
+ }
+ }
+ );
+
+ // Detect final-ui-startup for telemetry reporting
+ Services.obs.addObserver(function observer() {
+ AddonManagerPrivate.recordTimestamp("XPI_finalUIStartup");
+ Services.obs.removeObserver(observer, "final-ui-startup");
+ }, "final-ui-startup");
+
+ // If we haven't yet loaded the XPI database, schedule loading it
+ // to occur once other important startup work is finished. We want
+ // this to happen relatively quickly after startup so the telemetry
+ // environment has complete addon information.
+ //
+ // Unfortunately we have to use a variety of ways do detect when it
+ // is time to load. In a regular browser process we just wait for
+ // sessionstore-windows-restored. In a browser toolbox process
+ // we wait for the toolbox to show up, based on xul-window-visible
+ // and a visible toolbox window.
+ //
+ // TelemetryEnvironment's EnvironmentAddonBuilder awaits databaseReady
+ // before releasing a blocker on AddonManager.beforeShutdown, which in its
+ // turn is a blocker of a shutdown blocker at "profile-before-change".
+ // To avoid a deadlock, trigger the DB load at "profile-before-change" if
+ // the database hasn't started loading yet.
+ //
+ // Finally, we have a test-only event called test-load-xpi-database
+ // as a temporary workaround for bug 1372845. The latter can be
+ // cleaned up when that bug is resolved.
+ if (!this.isDBLoaded) {
+ const EVENTS = [
+ "sessionstore-windows-restored",
+ "xul-window-visible",
+ "profile-before-change",
+ "test-load-xpi-database",
+ ];
+ let observer = (subject, topic, data) => {
+ if (
+ topic == "xul-window-visible" &&
+ !Services.wm.getMostRecentWindow("devtools:toolbox")
+ ) {
+ return;
+ }
+
+ for (let event of EVENTS) {
+ Services.obs.removeObserver(observer, event);
+ }
+
+ XPIExports.XPIDatabase.asyncLoadDB();
+ };
+ for (let event of EVENTS) {
+ Services.obs.addObserver(observer, event);
+ }
+ }
+
+ AddonManagerPrivate.recordTimestamp("XPI_startup_end");
+
+ lazy.timerManager.registerTimer(
+ "xpi-signature-verification",
+ () => {
+ XPIExports.XPIDatabase.verifySignatures();
+ },
+ XPI_SIGNATURE_CHECK_PERIOD
+ );
+ } catch (e) {
+ logger.error("startup failed", e);
+ AddonManagerPrivate.recordException("XPI", "startup failed", e);
+ }
+ },
+
+ /**
+ * Shuts down the database and releases all references.
+ * Return: Promise{integer} resolves / rejects with the result of
+ * flushing the XPI Database if it was loaded,
+ * 0 otherwise.
+ */
+ async shutdown() {
+ logger.debug("shutdown");
+
+ this.activeAddons.clear();
+ this.allAppGlobal = true;
+
+ // Stop anything we were doing asynchronously
+ XPIExports.XPIInstall.cancelAll();
+
+ for (let install of XPIExports.XPIInstall.installs) {
+ if (install.onShutdown()) {
+ install.onShutdown();
+ }
+ }
+
+ // If there are pending operations then we must update the list of active
+ // add-ons
+ if (Services.prefs.getBoolPref(PREF_PENDING_OPERATIONS, false)) {
+ XPIExports.XPIDatabase.updateActiveAddons();
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, false);
+ }
+
+ await XPIExports.XPIDatabase.shutdown();
+ },
+
+ cleanupTemporaryAddons() {
+ let promises = [];
+ let tempLocation = TemporaryInstallLocation;
+ for (let [id, addon] of tempLocation.entries()) {
+ tempLocation.delete(id);
+
+ let bootstrap = BootstrapScope.get(addon);
+ let existing = XPIStates.findAddon(id, loc => !loc.isTemporary);
+
+ let cleanup = () => {
+ tempLocation.installer.uninstallAddon(id);
+ tempLocation.removeAddon(id);
+ };
+
+ let promise;
+ if (existing) {
+ promise = bootstrap.update(existing, false, () => {
+ cleanup();
+ XPIExports.XPIDatabase.makeAddonLocationVisible(
+ id,
+ existing.location
+ );
+ });
+ } else {
+ promise = bootstrap.uninstall().then(cleanup);
+ }
+ lazy.AsyncShutdown.profileChangeTeardown.addBlocker(
+ `Temporary extension shutdown: ${addon.id}`,
+ promise
+ );
+ promises.push(promise);
+ }
+ return Promise.all(promises);
+ },
+
+ /**
+ * Adds a list of currently active add-ons to the next crash report.
+ */
+ addAddonsToCrashReporter() {
+ void (Services.appinfo instanceof Ci.nsICrashReporter);
+ if (!Services.appinfo.annotateCrashReport || Services.appinfo.inSafeMode) {
+ return;
+ }
+
+ let data = Array.from(XPIStates.enabledAddons(), a => a.telemetryKey).join(
+ ","
+ );
+
+ try {
+ Services.appinfo.annotateCrashReport("Add-ons", data);
+ } catch (e) {}
+
+ lazy.TelemetrySession.setAddOns(data);
+ },
+
+ /**
+ * Check the staging directories of install locations for any add-ons to be
+ * installed or add-ons to be uninstalled.
+ *
+ * @param {Object} aManifests
+ * A dictionary to add detected install manifests to for the purpose
+ * of passing through updated compatibility information
+ * @returns {boolean}
+ * True if an add-on was installed or uninstalled
+ */
+ processPendingFileChanges(aManifests) {
+ let changed = false;
+ for (let loc of XPIStates.locations()) {
+ aManifests[loc.name] = {};
+ // We can't install or uninstall anything in locked locations
+ if (loc.locked) {
+ continue;
+ }
+
+ // Collect any install errors for specific removal from the staged directory
+ // during cleanStagingDir. Successful installs remove the files.
+ let stagedFailureNames = [];
+ let promises = [];
+ for (let [id, metadata] of loc.getStagedAddons()) {
+ loc.unstageAddon(id);
+
+ aManifests[loc.name][id] = null;
+ promises.push(
+ XPIExports.XPIInstall.installStagedAddon(id, metadata, loc).then(
+ addon => {
+ aManifests[loc.name][id] = addon;
+ },
+ error => {
+ delete aManifests[loc.name][id];
+ stagedFailureNames.push(`${id}.xpi`);
+
+ logger.error(
+ `Failed to install staged add-on ${id} in ${loc.name}`,
+ error
+ );
+ }
+ )
+ );
+ }
+
+ if (promises.length) {
+ changed = true;
+ awaitPromise(Promise.all(promises));
+ }
+
+ try {
+ if (changed || stagedFailureNames.length) {
+ loc.installer.cleanStagingDir(stagedFailureNames);
+ }
+ } catch (e) {
+ // Non-critical, just saves some perf on startup if we clean this up.
+ logger.debug("Error cleaning staging dir", e);
+ }
+ }
+ return changed;
+ },
+
+ /**
+ * Installs any add-ons located in the extensions directory of the
+ * application's distribution specific directory into the profile unless a
+ * newer version already exists or the user has previously uninstalled the
+ * distributed add-on.
+ *
+ * @param {Object} aManifests
+ * A dictionary to add new install manifests to to save having to
+ * reload them later
+ * @param {string} [aAppChanged]
+ * See checkForChanges
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @returns {boolean}
+ * True if any new add-ons were installed
+ */
+ installDistributionAddons(aManifests, aAppChanged, aOldAppVersion) {
+ let distroDirs = [];
+ try {
+ distroDirs.push(
+ lazy.FileUtils.getDir(KEY_APP_DISTRIBUTION, [DIR_EXTENSIONS])
+ );
+ } catch (e) {
+ return false;
+ }
+
+ let availableLocales = [];
+ for (let file of iterDirectory(distroDirs[0])) {
+ if (file.isDirectory() && file.leafName.startsWith("locale-")) {
+ availableLocales.push(file.leafName.replace("locale-", ""));
+ }
+ }
+
+ let locales = Services.locale.negotiateLanguages(
+ Services.locale.requestedLocales,
+ availableLocales,
+ undefined,
+ Services.locale.langNegStrategyMatching
+ );
+
+ // Also install addons from subdirectories that correspond to the requested
+ // locales. This allows for installing language packs and dictionaries.
+ for (let locale of locales) {
+ let langPackDir = distroDirs[0].clone();
+ langPackDir.append(`locale-${locale}`);
+ distroDirs.push(langPackDir);
+ }
+
+ let changed = false;
+ for (let distroDir of distroDirs) {
+ logger.warn(`Checking ${distroDir.path} for addons`);
+ for (let file of iterDirectory(distroDir)) {
+ if (!isXPI(file.leafName, true)) {
+ // Only warn for files, not directories
+ if (!file.isDirectory()) {
+ logger.warn(`Ignoring distribution: not an XPI: ${file.path}`);
+ }
+ continue;
+ }
+
+ let id = getExpectedID(file);
+ if (!id) {
+ logger.warn(
+ `Ignoring distribution: name is not a valid add-on ID: ${file.path}`
+ );
+ continue;
+ }
+
+ /* If this is not an upgrade and we've already handled this extension
+ * just continue */
+ if (
+ !aAppChanged &&
+ Services.prefs.prefHasUserValue(PREF_BRANCH_INSTALLED_ADDON + id)
+ ) {
+ continue;
+ }
+
+ try {
+ let loc = XPIStates.getLocation(KEY_APP_PROFILE);
+ let addon = awaitPromise(
+ XPIExports.XPIInstall.installDistributionAddon(
+ id,
+ file,
+ loc,
+ aOldAppVersion
+ )
+ );
+
+ if (addon) {
+ // aManifests may contain a copy of a newly installed add-on's manifest
+ // and we'll have overwritten that so instead cache our install manifest
+ // which will later be put into the database in processFileChanges
+ if (!(loc.name in aManifests)) {
+ aManifests[loc.name] = {};
+ }
+ aManifests[loc.name][id] = addon;
+ changed = true;
+ }
+ } catch (e) {
+ logger.error(`Failed to install distribution add-on ${file.path}`, e);
+ }
+ }
+ }
+
+ return changed;
+ },
+
+ /**
+ * 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 {Promise<Addon>} a Promise that resolves when the addon is installed.
+ */
+ async maybeInstallBuiltinAddon(aID, aVersion, aBase) {
+ let installed;
+ if (lazy.enabledScopes & BuiltInLocation.scope) {
+ let existing = BuiltInLocation.get(aID);
+ if (!existing || existing.version != aVersion) {
+ installed = this.installBuiltinAddon(aBase);
+ this.startupPromises.push(installed);
+ }
+ }
+ return installed;
+ },
+
+ getDependentAddons(aAddon) {
+ return Array.from(XPIExports.XPIDatabase.getAddons()).filter(addon =>
+ addon.dependencies.includes(aAddon.id)
+ );
+ },
+
+ /**
+ * Checks for any changes that have occurred since the last time the
+ * application was launched.
+ *
+ * @param {boolean?} [aAppChanged]
+ * A tri-state value. Undefined means the current profile was created
+ * for this session, true means the profile already existed but was
+ * last used with an application with a different version number,
+ * false means that the profile was last used by this version of the
+ * application.
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ */
+ checkForChanges(aAppChanged, aOldAppVersion, aOldPlatformVersion) {
+ logger.debug("checkForChanges");
+
+ // Keep track of whether and why we need to open and update the database at
+ // startup time.
+ let updateReasons = [];
+ if (aAppChanged) {
+ updateReasons.push("appChanged");
+ }
+
+ let installChanged = XPIStates.scanForChanges(aAppChanged === false);
+ if (installChanged) {
+ updateReasons.push("directoryState");
+ }
+
+ // First install any new add-ons into the locations, if there are any
+ // changes then we must update the database with the information in the
+ // install locations
+ let manifests = {};
+ let updated = this.processPendingFileChanges(manifests);
+ if (updated) {
+ updateReasons.push("pendingFileChanges");
+ }
+
+ // This will be true if the previous session made changes that affect the
+ // active state of add-ons but didn't commit them properly (normally due
+ // to the application crashing)
+ let hasPendingChanges = Services.prefs.getBoolPref(
+ PREF_PENDING_OPERATIONS,
+ false
+ );
+ if (hasPendingChanges) {
+ updateReasons.push("hasPendingChanges");
+ }
+
+ // If the application has changed then check for new distribution add-ons
+ if (Services.prefs.getBoolPref(PREF_INSTALL_DISTRO_ADDONS, true)) {
+ updated = this.installDistributionAddons(
+ manifests,
+ aAppChanged,
+ aOldAppVersion
+ );
+ if (updated) {
+ updateReasons.push("installDistributionAddons");
+ }
+ }
+
+ // If the schema appears to have changed then we should update the database
+ if (DB_SCHEMA != Services.prefs.getIntPref(PREF_DB_SCHEMA, 0)) {
+ // If we don't have any add-ons, just update the pref, since we don't need to
+ // write the database
+ if (!XPIStates.size) {
+ logger.debug(
+ "Empty XPI database, setting schema version preference to " +
+ DB_SCHEMA
+ );
+ Services.prefs.setIntPref(PREF_DB_SCHEMA, DB_SCHEMA);
+ } else {
+ updateReasons.push("schemaChanged");
+ }
+ }
+
+ // Catch and log any errors during the main startup
+ try {
+ let extensionListChanged = false;
+ // If the database needs to be updated then open it and then update it
+ // from the filesystem
+ if (updateReasons.length) {
+ AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_startup_load_reasons",
+ updateReasons
+ );
+ XPIExports.XPIDatabase.syncLoadDB(false);
+ try {
+ extensionListChanged =
+ XPIExports.XPIDatabaseReconcile.processFileChanges(
+ manifests,
+ aAppChanged,
+ aOldAppVersion,
+ aOldPlatformVersion,
+ updateReasons.includes("schemaChanged")
+ );
+ } catch (e) {
+ logger.error("Failed to process extension changes at startup", e);
+ }
+ }
+
+ // If the application crashed before completing any pending operations then
+ // we should perform them now.
+ if (extensionListChanged || hasPendingChanges) {
+ XPIExports.XPIDatabase.updateActiveAddons();
+ return;
+ }
+
+ logger.debug("No changes found");
+ } catch (e) {
+ logger.error("Error during startup file checks", e);
+ }
+ },
+
+ /**
+ * Gets an array of add-ons which were placed in a known install location
+ * prior to startup of the current session, were detected by a directory scan
+ * of those locations, and are currently disabled.
+ *
+ * @returns {Promise<Array<Addon>>}
+ */
+ async getNewSideloads() {
+ if (XPIStates.scanForChanges(false)) {
+ // We detected changes. Update the database to account for them.
+ await XPIExports.XPIDatabase.asyncLoadDB(false);
+ XPIExports.XPIDatabaseReconcile.processFileChanges({}, false);
+ XPIExports.XPIDatabase.updateActiveAddons();
+ }
+
+ let addons = await Promise.all(
+ Array.from(XPIStates.sideLoadedAddons.keys(), id => this.getAddonByID(id))
+ );
+
+ return addons.filter(
+ addon =>
+ addon &&
+ addon.seen === false &&
+ addon.permissions & AddonManager.PERM_CAN_ENABLE
+ );
+ },
+
+ /**
+ * Called to test whether this provider supports installing a particular
+ * mimetype.
+ *
+ * @param {string} aMimetype
+ * The mimetype to check for
+ * @returns {boolean}
+ * True if the mimetype is application/x-xpinstall
+ */
+ supportsMimetype(aMimetype) {
+ return aMimetype == "application/x-xpinstall";
+ },
+
+ // Identify temporary install IDs.
+ isTemporaryInstallID(id) {
+ return id.endsWith(TEMPORARY_ADDON_SUFFIX);
+ },
+
+ /**
+ * Sets startupData for the given addon. The provided data will be stored
+ * in addonsStartup.json so it is available early during browser startup.
+ * Note that this file is read synchronously at startup, so startupData
+ * should be used with care.
+ *
+ * @param {string} aID
+ * The id of the addon to save startup data for.
+ * @param {any} aData
+ * The data to store. Must be JSON serializable.
+ */
+ setStartupData(aID, aData) {
+ let state = XPIStates.findAddon(aID);
+ state.startupData = aData;
+ XPIStates.save();
+ },
+
+ /**
+ * Persists some startupData into an addon if it is available in the current
+ * XPIState for the addon id.
+ *
+ * @param {AddonInternal} addon An addon to receive the startup data, typically an update that is occuring.
+ * @param {XPIState} state optional
+ */
+ persistStartupData(addon, state) {
+ if (!addon.startupData) {
+ state = state || XPIStates.findAddon(addon.id);
+ if (state?.enabled) {
+ // Save persistent listener data if available. It will be
+ // removed later if necessary.
+ let persistentListeners = state.startupData?.persistentListeners;
+ addon.startupData = { persistentListeners };
+ }
+ }
+ },
+
+ getAddonIDByInstanceID(aInstanceID) {
+ if (!aInstanceID || typeof aInstanceID != "symbol") {
+ throw Components.Exception(
+ "aInstanceID must be a Symbol()",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ for (let [id, val] of this.activeAddons) {
+ if (aInstanceID == val.instanceID) {
+ return id;
+ }
+ }
+
+ return null;
+ },
+
+ async getAddonsByTypes(aTypes) {
+ if (aTypes && !aTypes.some(type => ALL_XPI_TYPES.has(type))) {
+ return [];
+ }
+ return XPIExports.XPIDatabase.getAddonsByTypes(aTypes);
+ },
+
+ /**
+ * Called to get active Addons of a particular type
+ *
+ * @param {Array<string>?} aTypes
+ * An array of types to fetch. Can be null to get all types.
+ * @returns {Promise<Array<Addon>>}
+ */
+ async getActiveAddons(aTypes) {
+ // If we already have the database loaded, returning full info is fast.
+ if (this.isDBLoaded) {
+ let addons = await this.getAddonsByTypes(aTypes);
+ return {
+ addons: addons.filter(addon => addon.isActive),
+ fullData: true,
+ };
+ }
+
+ let result = [];
+ for (let addon of XPIStates.enabledAddons()) {
+ if (aTypes && !aTypes.includes(addon.type)) {
+ continue;
+ }
+ let { scope, isSystem } = addon.location;
+ result.push({
+ id: addon.id,
+ version: addon.version,
+ type: addon.type,
+ updateDate: addon.lastModifiedTime,
+ scope,
+ isSystem,
+ isWebExtension: addon.isWebExtension,
+ });
+ }
+
+ return { addons: result, fullData: false };
+ },
+
+ /*
+ * Notified when a preference we're interested in has changed.
+ *
+ * @see nsIObserver
+ */
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case NOTIFICATION_FLUSH_PERMISSIONS:
+ if (!aData || aData == XPI_PERMISSION) {
+ XPIExports.XPIDatabase.importPermissions();
+ }
+ break;
+
+ case "nsPref:changed":
+ switch (aData) {
+ case PREF_XPI_SIGNATURES_REQUIRED:
+ case PREF_LANGPACK_SIGNATURES:
+ XPIExports.XPIDatabase.updateAddonAppDisabledStates();
+ break;
+ }
+ }
+ },
+
+ uninstallSystemProfileAddon(aID) {
+ let location = XPIStates.getLocation(KEY_APP_SYSTEM_PROFILE);
+ return XPIExports.XPIInstall.uninstallAddonFromLocation(aID, location);
+ },
+};
+
+for (let meth of [
+ "getInstallForFile",
+ "getInstallForURL",
+ "getInstallsByTypes",
+ "installTemporaryAddon",
+ "installBuiltinAddon",
+ "isInstallAllowed",
+ "isInstallEnabled",
+ "updateSystemAddons",
+ "stageLangpacksForAppUpdate",
+]) {
+ XPIProvider[meth] = function () {
+ return XPIExports.XPIInstall[meth](...arguments);
+ };
+}
+
+for (let meth of [
+ "addonChanged",
+ "getAddonByID",
+ "getAddonBySyncGUID",
+ "updateAddonRepositoryData",
+ "updateAddonAppDisabledStates",
+]) {
+ XPIProvider[meth] = function () {
+ return XPIExports.XPIDatabase[meth](...arguments);
+ };
+}
+
+export var XPIInternal = {
+ BOOTSTRAP_REASONS,
+ BootstrapScope,
+ BuiltInLocation,
+ DB_SCHEMA,
+ DIR_STAGE,
+ DIR_TRASH,
+ KEY_APP_PROFILE,
+ KEY_APP_SYSTEM_PROFILE,
+ KEY_APP_SYSTEM_ADDONS,
+ KEY_APP_SYSTEM_DEFAULTS,
+ PREF_BRANCH_INSTALLED_ADDON,
+ PREF_SYSTEM_ADDON_SET,
+ SystemAddonLocation,
+ TEMPORARY_ADDON_SUFFIX,
+ TemporaryInstallLocation,
+ XPIStates,
+ XPI_PERMISSION,
+ awaitPromise,
+ canRunInSafeMode,
+ getURIForResourceInFile,
+ isXPI,
+ iterDirectory,
+ maybeResolveURI,
+ migrateAddonLoader,
+ resolveDBReady,
+
+ // Used by tests to shut down AddonManager.
+ overrideAsyncShutdown(mockAsyncShutdown) {
+ lazy.AsyncShutdown = mockAsyncShutdown;
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs b/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs
new file mode 100644
index 0000000000..b1304bcdfc
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs
@@ -0,0 +1,57 @@
+/* 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/. */
+
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+
+export function computeHashAsString(hashType, input) {
+ const data = new Uint8Array(new TextEncoder().encode(input));
+ const crypto = CryptoHash(hashType);
+ crypto.update(data, data.length);
+ return getHashStringForCrypto(crypto);
+}
+
+/**
+ * Returns the string representation (hex) of the SHA256 hash of `input`.
+ *
+ * @param {string} input
+ * The value to hash.
+ * @returns {string}
+ * The hex representation of a SHA256 hash.
+ */
+export function computeSha256HashAsString(input) {
+ return computeHashAsString("sha256", input);
+}
+
+/**
+ * Returns the string representation (hex) of the SHA1 hash of `input`.
+ *
+ * @param {string} input
+ * The value to hash.
+ * @returns {string}
+ * The hex representation of a SHA1 hash.
+ */
+export function computeSha1HashAsString(input) {
+ return computeHashAsString("sha1", input);
+}
+
+/**
+ * Returns the string representation (hex) of a given CryptoHashInstance.
+ *
+ * @param {CryptoHash} aCrypto
+ * @returns {string}
+ * The hex representation of a SHA256 hash.
+ */
+export function getHashStringForCrypto(aCrypto) {
+ // return the two-digit hexadecimal code for a byte
+ let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+ // convert the binary hash data to a hex string.
+ let binary = aCrypto.finish(/* base64 */ false);
+ let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
+ return hash.join("").toLowerCase();
+}
diff --git a/toolkit/mozapps/extensions/internal/moz.build b/toolkit/mozapps/extensions/internal/moz.build
new file mode 100644
index 0000000000..4fcf6657d5
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/moz.build
@@ -0,0 +1,29 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+EXTRA_JS_MODULES.addons += [
+ "AddonRepository.sys.mjs",
+ "AddonSettings.sys.mjs",
+ "AddonUpdateChecker.sys.mjs",
+ "crypto-utils.sys.mjs",
+ "ProductAddonChecker.sys.mjs",
+ "siteperms-addon-utils.sys.mjs",
+ "XPIDatabase.sys.mjs",
+ "XPIExports.sys.mjs",
+ "XPIInstall.sys.mjs",
+ "XPIProvider.sys.mjs",
+]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] != "android":
+ EXTRA_JS_MODULES.addons += [
+ "GMPProvider.sys.mjs",
+ ## TODO consider extending it to mobile builds too (See Bug 1790084).
+ "SitePermsAddonProvider.sys.mjs",
+ ]
+
+TESTING_JS_MODULES += [
+ "AddonTestUtils.sys.mjs",
+]
diff --git a/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs b/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs
new file mode 100644
index 0000000000..86853d7168
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs
@@ -0,0 +1,72 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+export const GATED_PERMISSIONS = ["midi", "midi-sysex"];
+export const SITEPERMS_ADDON_PROVIDER_PREF =
+ "dom.sitepermsaddon-provider.enabled";
+export const SITEPERMS_ADDON_TYPE = "sitepermission";
+export const SITEPERMS_ADDON_BLOCKEDLIST_PREF =
+ "dom.sitepermsaddon-provider.separatedBlocklistedDomains";
+
+const lazy = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "blocklistedOriginsSet",
+ SITEPERMS_ADDON_BLOCKEDLIST_PREF,
+ // Default value
+ "",
+ // onUpdate
+ null,
+ // transform
+ prefValue => new Set(prefValue.split(","))
+);
+
+/**
+ * @param {string} type
+ * @returns {boolean}
+ */
+export function isGatedPermissionType(type) {
+ return GATED_PERMISSIONS.includes(type);
+}
+
+/**
+ * @param {string} siteOrigin
+ * @returns {boolean}
+ */
+export function isKnownPublicSuffix(siteOrigin) {
+ const { host } = new URL(siteOrigin);
+
+ let isPublic = false;
+ // getKnownPublicSuffixFromHost throws when passed an IP, in such case, assume
+ // this is not a public etld.
+ try {
+ isPublic = Services.eTLD.getKnownPublicSuffixFromHost(host) == host;
+ } catch (e) {}
+
+ return isPublic;
+}
+
+/**
+ * ⚠️ This should be only used for testing purpose ⚠️
+ *
+ * @param {Array<String>} permissionTypes
+ * @throws if not called from xpcshell test
+ */
+export function addGatedPermissionTypesForXpcShellTests(permissionTypes) {
+ if (!Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+ throw new Error("This should only be called from XPCShell tests");
+ }
+
+ GATED_PERMISSIONS.push(...permissionTypes);
+}
+
+/**
+ * @param {nsIPrincipal} principal
+ * @returns {Boolean}
+ */
+export function isPrincipalInSitePermissionsBlocklist(principal) {
+ return lazy.blocklistedOriginsSet.has(principal.baseDomain);
+}