summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs')
-rw-r--r--toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs4897
1 files changed, 4897 insertions, 0 deletions
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,
+};