--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -358,16 +358,32 @@ this.ExtensionData = class {
resolve(JSON.parse(text));
} catch (e) {
reject(e);
}
});
});
}
+ // This method should return a structured representation of any
+ // capabilities this extension has access to, as derived from the
+ // manifest. The current implementation just returns the contents
+ // of the permissions attribute, if we add things like url_overrides,
+ // they should also be added here.
+ userPermissions() {
+ let result = {
+ hosts: this.whiteListedHosts.pat,
+ apis: [...this.apiNames],
+ };
+ const EXP_PATTERN = /^experiments\.\w+/;
+ result.permissions = [...this.permissions]
+ .filter(p => !result.hosts.includes(p) && !EXP_PATTERN.test(p));
+ return result;
+ }
+
// Reads the extension's |manifest.json| file, and stores its
// parsed contents in |this.manifest|.
readManifest() {
return Promise.all([
this.readJSON("manifest.json"),
Management.lazyInit(),
]).then(([manifest]) => {
this.manifest = manifest;
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -1865,23 +1865,22 @@ var AddonManagerInternal = {
* @param aHash
* An optional hash of the add-on
* @param aName
* An optional placeholder name while the add-on is being downloaded
* @param aIcons
* Optional placeholder icons while the add-on is being downloaded
* @param aVersion
* An optional placeholder version while the add-on is being downloaded
- * @param aLoadGroup
- * An optional nsILoadGroup to associate any network requests with
+ * @param aBrowser
+ * An optional <browser> element for download permissions prompts.
* @throws if the aUrl, aCallback or aMimetype arguments are not specified
*/
- getInstallForURL: function(aUrl, aCallback, aMimetype,
- aHash, aName, aIcons,
- aVersion, aBrowser) {
+ getInstallForURL: function(aUrl, aCallback, aMimetype, aHash, aName,
+ aIcons, aVersion, aBrowser) {
if (!gStarted)
throw Components.Exception("AddonManager is not initialized",
Cr.NS_ERROR_NOT_INITIALIZED);
if (!aUrl || typeof aUrl != "string")
throw Components.Exception("aURL must be a non-empty string",
Cr.NS_ERROR_INVALID_ARG);
@@ -3194,26 +3193,32 @@ this.AddonManager = {
// The install is being downloaded.
["STATE_DOWNLOADING", 1],
// The install is checking for compatibility information.
["STATE_CHECKING", 2],
// The install is downloaded and ready to install.
["STATE_DOWNLOADED", 3],
// The download failed.
["STATE_DOWNLOAD_FAILED", 4],
+ // The install may not proceed until the user accepts permissions
+ ["STATE_AWAITING_PERMISSIONS", 5],
+ // Any permission prompts are done
+ ["STATE_PERMISSION_GRANTED", 6],
// The install has been postponed.
- ["STATE_POSTPONED", 5],
+ ["STATE_POSTPONED", 7],
+ // The install is ready to be applied.
+ ["STATE_READY", 8],
// The add-on is being installed.
- ["STATE_INSTALLING", 6],
+ ["STATE_INSTALLING", 9],
// The add-on has been installed.
- ["STATE_INSTALLED", 7],
+ ["STATE_INSTALLED", 10],
// The install failed.
- ["STATE_INSTALL_FAILED", 8],
+ ["STATE_INSTALL_FAILED", 11],
// The install has been cancelled.
- ["STATE_CANCELLED", 9],
+ ["STATE_CANCELLED", 12],
]),
// Constants representing different types of errors while downloading an
// add-on.
// These will show up as AddonManager.ERROR_* (eg, ERROR_NETWORK_FAILURE)
_errors: new Map([
// The download failed due to network problems.
["ERROR_NETWORK_FAILURE", -1],
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -989,16 +989,17 @@ var loadManifestFromWebManifest = Task.a
else
addon.optionsType = AddonManager.OPTIONS_TYPE_INLINE_BROWSER;
}
// WebExtensions don't use iconURLs
addon.iconURL = null;
addon.icon64URL = null;
addon.icons = manifest.icons || {};
+ addon.userPermissions = extension.userPermissions();
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;
@@ -1327,16 +1328,17 @@ let loadManifestFromRDF = Task.async(fun
if (addon.type == "experiment") {
addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
addon.updateURL = null;
addon.updateKey = null;
}
// icons will be filled by the calling function
addon.icons = {};
+ addon.userPermissions = null;
return addon;
});
function defineSyncGUID(aAddon) {
// Define .syncGUID as a lazy property which is also settable
Object.defineProperty(aAddon, "syncGUID", {
get: () => {
@@ -3978,19 +3980,34 @@ this.XPIProvider = {
* A version for the install
* @param aBrowser
* The browser performing the install
* @param aCallback
* A callback to pass the AddonInstall to
*/
getInstallForURL: function(aUrl, aHash, aName, aIcons, aVersion, aBrowser,
aCallback) {
- createDownloadInstall(function(aInstall) {
- aCallback(aInstall.wrapper);
- }, aUrl, aHash, aName, aIcons, aVersion, aBrowser);
+ let location = XPIProvider.installLocationsByName[KEY_APP_PROFILE];
+ let url = NetUtil.newURI(aUrl);
+
+ let options = {
+ hash: aHash,
+ browser: aBrowser,
+ name: aName,
+ icons: aIcons,
+ version: aVersion,
+ };
+
+ if (url instanceof Ci.nsIFileURL) {
+ let install = new LocalAddonInstall(location, url, options);
+ install.init().then(() => { aCallback(install.wrapper); });
+ } else {
+ let install = new DownloadAddonInstall(location, url, options);
+ aCallback(install.wrapper);
+ }
},
/**
* Called to get an AddonInstall to install an add-on from a local file.
*
* @param aFile
* The file to be installed
* @param aCallback
@@ -5338,55 +5355,68 @@ function getHashStringForCrypto(aCrypto)
/**
* 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 aInstallLocation
+ * @param installLocation
* The install location the add-on will be installed into
- * @param aUrl
+ * @param 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 aHash
+ * @param options
+ * Additional options for the install
+ * @param options.hash
* An optional hash for the add-on
- * @param aExistingAddon
+ * @param options.existingAddon
* The add-on this install will update if known
- */
- constructor(aInstallLocation, aUrl, aHash, aExistingAddon) {
+ * @param options.name
+ * An optional name for the add-on
+ * @param options.type
+ * An optional type for the add-on
+ * @param options.icons
+ * Optional icons for the add-on
+ * @param options.version
+ * An optional version for the add-on
+ * @param options.permHandler
+ * A callback to present permissions to the user before installing.
+ */
+ constructor(installLocation, url, options = {}) {
this.wrapper = new AddonInstallWrapper(this);
- this.installLocation = aInstallLocation;
- this.sourceURI = aUrl;
-
- if (aHash) {
- let hashSplit = aHash.toLowerCase().split(":");
+ this.installLocation = 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.existingAddon = aExistingAddon;
+ this.existingAddon = options.existingAddon || null;
+ this.permHandler = options.permHandler || (() => Promise.resolve());
this.releaseNotesURI = null;
this.listeners = [];
- this.icons = {};
+ 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 = null;
- this.type = null;
- this.version = null;
+ this.name = options.name || null;
+ this.type = options.type || null;
+ this.version = options.version || null;
this.file = null;
this.ownsTempFile = null;
this.certificate = null;
this.certName = null;
this.linkedInstalls = null;
this.addon = null;
@@ -5402,16 +5432,22 @@ class AddonInstall {
* Note this method is overridden to handle additional state in
* the subclassses below.
*
* @throws if installation cannot proceed from the current state
*/
install() {
switch (this.state) {
case AddonManager.STATE_DOWNLOADED:
+ this.checkPermissions();
+ break;
+ case AddonManager.STATE_PERMISSION_GRANTED:
+ 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:
case AddonManager.STATE_INSTALLING:
@@ -5772,16 +5808,71 @@ class AddonInstall {
this.addon.compatibilityOverrides = repoAddon ?
repoAddon.compatibilityOverrides :
null;
this.addon.appDisabled = !isUsableAddon(this.addon);
return undefined;
}).bind(this));
}
+ /**
+ * 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).
+ */
+ checkPermissions() {
+ Task.spawn((function*() {
+ if (this.permHandler) {
+ let info = {
+ existinAddon: this.existingAddon,
+ addon: this.addon,
+ };
+
+ try {
+ yield this.permHandler(info);
+ } catch (err) {
+ logger.info(`Install of ${this.addon.id} cancelled since user declined permissions`);
+ this.state = AddonManager.STATE_CANCELLED;
+ XPIProvider.removeActiveInstall(this);
+ AddonManagerPrivate.callInstallListeners("onInstallCancelled",
+ this.listeners, this.wrapper);
+ return;
+ }
+ }
+ this.state = AddonManager.STATE_PERMISSION_GRANTED;
+ this.install();
+ }).bind(this));
+ }
+
+ /**
+ * 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 = () => {
+ logger.info(`${this.addon.id} has resumed a previously postponed upgrade`);
+ this.state = AddonManager.STATE_READY;
+ this.install();
+ }
+ this.postpone(resumeFn);
+ return;
+ }
+
+ this.state = AddonManager.STATE_READY;
+ this.install();
+ }
+
// TODO This relies on the assumption that we are always installing into the
// highest priority install location so the resulting add-on will be visible
// overriding any existing copy in another install location (bug 557710).
/**
* Installs the add-on into the install location.
*/
startInstall() {
this.state = AddonManager.STATE_INSTALLING;
@@ -6184,43 +6275,42 @@ class LocalAddonInstall extends AddonIns
class DownloadAddonInstall extends AddonInstall {
/**
* Instantiates a DownloadAddonInstall
*
* @param installLocation
* The InstallLocation the add-on will be installed into
* @param url
* The nsIURL to get the add-on from
- * @param name
- * An optional name for the add-on
- * @param hash
+ * @param options
+ * Additional options for the install
+ * @param options.hash
* An optional hash for the add-on
- * @param existingAddon
+ * @param options.existingAddon
* The add-on this install will update if known
- * @param browser
+ * @param options.browser
* The browser performing the install, used to display
* authentication prompts.
- * @param type
+ * @param options.name
+ * An optional name for the add-on
+ * @param options.type
* An optional type for the add-on
- * @param icons
+ * @param options.icons
* Optional icons for the add-on
- * @param version
+ * @param options.version
* An optional version for the add-on
- */
- constructor(installLocation, url, hash, existingAddon, browser,
- name, type, icons, version) {
- super(installLocation, url, hash, existingAddon);
-
- this.browser = browser;
+ * @param options.permHandler
+ * A callback to present permissions to the user before installing.
+ */
+ constructor(installLocation, url, options={}) {
+ super(installLocation, url, options);
+
+ this.browser = options.browser;
this.state = AddonManager.STATE_AVAILABLE;
- this.name = name;
- this.type = type;
- this.version = version;
- this.icons = icons;
this.stream = null;
this.crypto = null;
this.badCertHandler = null;
this.restartDownload = false;
AddonManagerPrivate.callInstallListeners("onNewInstall", this.listeners,
this.wrapper);
@@ -6566,34 +6656,22 @@ class DownloadAddonInstall extends Addon
if (AddonManagerPrivate.callInstallListeners("onDownloadEnded",
this.listeners,
this.wrapper)) {
// If a listener changed our state then do not proceed with the install
if (this.state != AddonManager.STATE_DOWNLOADED)
return;
- // 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 = () => {
- logger.info(`${this.addon.id} has resumed a previously postponed upgrade`);
- this.state = AddonManager.STATE_DOWNLOADED;
- this.install();
- }
- this.postpone(resumeFn);
- } else {
- // no upgrade listener present, so proceed with normal install
- this.install();
- if (this.linkedInstalls) {
- for (let install of this.linkedInstalls) {
- if (install.state == AddonManager.STATE_DOWNLOADED)
- install.install();
- }
+ // proceed with the install state machine.
+ this.install();
+ if (this.linkedInstalls) {
+ for (let install of this.linkedInstalls) {
+ if (install.state == AddonManager.STATE_DOWNLOADED)
+ install.install();
}
}
}
});
}
getInterface(iid) {
if (iid.equals(Ci.nsIAuthPrompt2)) {
@@ -6670,73 +6748,43 @@ function createLocalInstall(file, locati
catch (e) {
logger.error("Error creating install", e);
XPIProvider.removeActiveInstall(this);
return Promise.resolve(null);
}
}
/**
- * Creates a new AddonInstall to download and install a URL.
- *
- * @param aCallback
- * The callback to pass the new AddonInstall to
- * @param aUri
- * The URI to download
- * @param aHash
- * A hash for the add-on
- * @param aName
- * A name for the add-on
- * @param aIcons
- * An icon URLs for the add-on
- * @param aVersion
- * A version for the add-on
- * @param aBrowser
- * The browser performing the install
- */
-function createDownloadInstall(aCallback, aUri, aHash, aName, aIcons,
- aVersion, aBrowser) {
- let location = XPIProvider.installLocationsByName[KEY_APP_PROFILE];
- let url = NetUtil.newURI(aUri);
-
- if (url instanceof Ci.nsIFileURL) {
- let install = new LocalAddonInstall(location, url, aHash);
- install.init().then(() => { aCallback(install); });
- } else {
- let install = new DownloadAddonInstall(location, url, aHash, null,
- aBrowser, aName, null, aIcons,
- aVersion);
- aCallback(install);
- }
-}
-
-/**
* Creates a new AddonInstall for an update.
*
* @param aCallback
* The callback to pass the new AddonInstall to
* @param aAddon
* The add-on being updated
* @param aUpdate
* The metadata about the new version from the update manifest
*/
function createUpdate(aCallback, aAddon, aUpdate) {
let url = NetUtil.newURI(aUpdate.updateURL);
Task.spawn(function*() {
+ let opts = {
+ hash: aUpdate.updateHash,
+ existingAddon: aAddon,
+ name: aAddon.selectedLocale.name,
+ type: aAddon.type,
+ icons: aAddon.icons,
+ version: aUpdate.version,
+ };
let install;
if (url instanceof Ci.nsIFileURL) {
- install = new LocalAddonInstall(aAddon._installLocation, url,
- aUpdate.updateHash, aAddon);
+ install = new LocalAddonInstall(aAddon._installLocation, url, opts);
yield install.init();
} else {
- install = new DownloadAddonInstall(aAddon._installLocation, url,
- aUpdate.updateHash, aAddon, null,
- aAddon.selectedLocale.name, aAddon.type,
- aAddon.icons, aUpdate.version);
+ install = new DownloadAddonInstall(aAddon._installLocation, url, opts);
}
try {
if (aUpdate.updateInfoURL)
install.releaseNotesURI = NetUtil.newURI(escapeAddonURI(aAddon, aUpdate.updateInfoURL));
}
catch (e) {
// If the releaseNotesURI cannot be parsed then just ignore it.
}
@@ -6789,16 +6837,20 @@ AddonInstallWrapper.prototype = {
get linkedInstalls() {
let install = installFor(this);
if (!install.linkedInstalls)
return null;
return install.linkedInstalls.map(i => i.wrapper);
},
+ set _permHandler(handler) {
+ installFor(this).permHandler = handler;
+ },
+
install: function() {
installFor(this).install();
},
cancel: function() {
installFor(this).cancel();
},
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -42,16 +42,18 @@ const { OS } = Components.utils.import("
Components.utils.import("resource://gre/modules/AsyncShutdown.jsm");
Components.utils.import("resource://testing-common/AddonTestUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Extension",
"resource://gre/modules/Extension.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionTestUtils",
"resource://testing-common/ExtensionXPCShellUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionTestCommon",
+ "resource://testing-common/ExtensionTestCommon.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "HttpServer",
"resource://testing-common/httpd.js");
XPCOMUtils.defineLazyModuleGetter(this, "MockAsyncShutdown",
"resource://testing-common/AddonTestUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "MockRegistrar",
"resource://testing-common/MockRegistrar.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "MockRegistry",
"resource://testing-common/MockRegistry.jsm");
@@ -65,16 +67,17 @@ const {
getFileForAddon,
manuallyInstall,
manuallyUninstall,
promiseAddonByID,
promiseAddonEvent,
promiseAddonsByIDs,
promiseAddonsWithOperationsByTypes,
promiseCompleteAllInstalls,
+ promiseCompleteInstall,
promiseConsoleOutput,
promiseFindAddonUpdates,
promiseInstallAllFiles,
promiseInstallFile,
promiseRestartManager,
promiseSetExtensionModifiedTime,
promiseShutdownManager,
promiseStartupManager,
--- a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js
@@ -471,8 +471,81 @@ add_task(function* test_strict_min_max()
addon = yield promiseAddonByID(newId);
notEqual(addon, null, "Add-on is installed");
equal(addon.id, newId, "Installed add-on has the expected ID");
yield extension.unload();
AddonManager.checkCompatibility = savedCheckCompatibilityValue;
});
+
+// Check permissions prompt
+add_task(function* test_permissions() {
+ const manifest = {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version: 2,
+ version: "1.0",
+
+ permissions: ["tabs", "storage", "https://*.example.com/*", "<all_urls>", "experiments.test"],
+ };
+
+ let xpi = ExtensionTestCommon.generateXPI({manifest});
+
+ let install = yield new Promise(resolve => {
+ AddonManager.getInstallForFile(xpi, resolve);
+ });
+
+ let perminfo;
+ install._permHandler = info => {
+ perminfo = info;
+ return Promise.resolve();
+ };
+
+ yield promiseCompleteInstall(install);
+
+ notEqual(perminfo, undefined, "Permission handler was invoked");
+ equal(perminfo.existingAddon, null, "Permission info does not include an existing addon");
+ notEqual(perminfo.addon, null, "Permission info includes the new addon");
+ let perms = perminfo.addon.userPermissions;
+ deepEqual(perms.permissions, ["tabs", "storage"], "API permissions are correct");
+ deepEqual(perms.hosts, ["https://*.example.com/*", "<all_urls>"], "Host permissions are correct");
+ deepEqual(perms.apis, ["test"], "Experiments permissions are correct");
+
+ let addon = yield promiseAddonByID(perminfo.addon.id);
+ notEqual(addon, null, "Extension was installed");
+
+ addon.uninstall();
+ yield OS.File.remove(xpi.path);
+});
+
+// Check permissions prompt cancellation
+add_task(function* test_permissions() {
+ const manifest = {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version: 2,
+ version: "1.0",
+
+ permissions: ["webRequestBlocking"],
+ };
+
+ let xpi = ExtensionTestCommon.generateXPI({manifest});
+
+ let install = yield new Promise(resolve => {
+ AddonManager.getInstallForFile(xpi, resolve);
+ });
+
+ let perminfo;
+ install._permHandler = info => {
+ perminfo = info;
+ return Promise.reject();
+ };
+
+ yield promiseCompleteInstall(install);
+
+ notEqual(perminfo, undefined, "Permission handler was invoked");
+
+ let addon = yield promiseAddonByID(perminfo.addon.id);
+ equal(addon, null, "Extension was not installed");
+
+ yield OS.File.remove(xpi.path);
+});