Bug 1496632 - Add WebExtension permission prompts to notification UI; r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Fri, 19 Oct 2018 21:59:41 +1300
changeset 33418 e16ec20c7395cf961894b9d67936208ad906ab31
parent 33417 ba7329940979e5879434eda94842f528a1b06f93
child 33419 7d6de3006fc3475ffb21d6982adc60a32c48c7cc
push id387
push userclokep@gmail.com
push dateMon, 10 Dec 2018 21:30:47 +0000
reviewersmkmelin
bugs1496632
Bug 1496632 - Add WebExtension permission prompts to notification UI; r=mkmelin
mail/app/profile/all-thunderbird.js
mail/base/modules/ExtensionsUI.jsm
mail/locales/en-US/chrome/messenger/addons.properties
--- a/mail/app/profile/all-thunderbird.js
+++ b/mail/app/profile/all-thunderbird.js
@@ -167,16 +167,19 @@ pref("extensions.getAddons.search.url", 
 pref("extensions.webservice.discoverURL", "https://services.addons.thunderbird.net/%LOCALE%/%APP%/discovery/pane/%VERSION%/%OS%");
 pref("extensions.getAddons.siteRegExp", "^https://.*addons\\.thunderbird\\.net");
 
 // Blocklist preferences
 pref("extensions.blocklist.url", "https://blocklists.settings.services.mozilla.com/v1/blocklist/3/%APP_ID%/%APP_VERSION%/%PRODUCT%/%BUILD_ID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/%PING_COUNT%/%TOTAL_PING_COUNT%/%DAYS_SINCE_LAST_PING%/");
 pref("extensions.blocklist.detailsURL", "https://blocked.cdn.mozilla.net/");
 pref("extensions.blocklist.itemURL", "https://blocked.cdn.mozilla.net/%blockID%.html");
 
+// Show new install UI with permission lists
+pref("extensions.webextPermissionPrompts", true);
+
 // 1 = allow "Man In The Middle" (local proxy, web filter, etc.) for certificate
 //     pinning checks.
 pref("security.cert_pinning.enforcement_level", 1);
 
 // Symmetric (can be overridden by individual extensions) update preferences.
 // e.g.
 //  extensions.{GUID}.update.enabled
 //  extensions.{GUID}.update.url
--- a/mail/base/modules/ExtensionsUI.jsm
+++ b/mail/base/modules/ExtensionsUI.jsm
@@ -1,29 +1,35 @@
 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
  * 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.EXPORTED_SYMBOLS = [];
 
+const ADDONS_PROPERTIES = "chrome://messenger/locale/addons.properties";
+const BRAND_PROPERTIES = "chrome://branding/locale/brand.properties";
+const DEFAULT_EXTENSION_ICON = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
+  ExtensionData: "resource://gre/modules/Extension.jsm",
   PluralForm: "resource://gre/modules/PluralForm.jsm",
   Services: "resource://gre/modules/Services.jsm",
   setTimeout: "resource://gre/modules/Timer.jsm",
   StringBundle: "resource:///modules/StringBundle.js",
 });
 
 XPCOMUtils.defineLazyGetter(this, "addonsBundle", function() {
-  return new StringBundle("chrome://messenger/locale/addons.properties");
+  return new StringBundle(ADDONS_PROPERTIES);
 });
 XPCOMUtils.defineLazyGetter(this, "brandBundle", function() {
-  return new StringBundle("chrome://branding/locale/brand.properties");
+  return new StringBundle(BRAND_PROPERTIES);
 });
 
 function getNotification(id, browser) {
   return browser.ownerGlobal.PopupNotifications.getNotification(id, browser);
 }
 
 function showNotification(browser, ...args) {
   let notifications = browser.ownerGlobal.PopupNotifications;
@@ -54,16 +60,17 @@ function removeNotificationOnEnd(notific
       onInstallFailed: maybeRemove,
       onInstallEnded: maybeRemove,
     });
   }
 }
 
 var gXPInstallObserver = {
   pendingInstalls: new WeakMap(),
+  pendingNotifications: new WeakMap(),
 
   showInstallConfirmation(browser, installInfo, height = undefined) {
     let document = browser.ownerDocument;
     // If the confirmation notification is already open cache the installInfo
     // and the new confirmation will be shown later
     if (getNotification("addon-install-confirmation", browser)) {
       let pending = this.pendingInstalls.get(browser);
       if (pending) {
@@ -174,19 +181,135 @@ var gXPInstallObserver = {
       notification.style.minHeight = height + "px";
     }
 
     let popup = showNotification(browser, "addon-install-confirmation", messageString, anchorID,
                                  action, [secondaryAction], options);
     removeNotificationOnEnd(popup, installInfo.installs);
   },
 
+  async showPermissionsPrompt(browser, strings, icon) {
+    let window = browser.ownerGlobal;
+
+    // Wait for any pending prompts in this window to complete before
+    // showing the next one.
+    let pending;
+    while ((pending = this.pendingNotifications.get(window))) {
+      await pending;
+    }
+
+    let promise = new Promise(resolve => {
+      function eventCallback(topic) {
+        let doc = this.browser.ownerDocument;
+        if (topic == "showing") {
+          let textEl = doc.getElementById("addon-webext-perm-text");
+          textEl.textContent = strings.text;
+          textEl.hidden = !strings.text;
+
+          let listIntroEl = doc.getElementById("addon-webext-perm-intro");
+          listIntroEl.textContent = strings.listIntro;
+          listIntroEl.hidden = (strings.msgs.length == 0);
+
+          let list = doc.getElementById("addon-webext-perm-list");
+          while (list.firstChild) {
+            list.firstChild.remove();
+          }
+
+          for (let msg of strings.msgs) {
+            let item = doc.createElementNS(HTML_NS, "li");
+            item.textContent = msg;
+            list.appendChild(item);
+          }
+        } else if (topic == "swapping") {
+          return true;
+        }
+        if (topic == "removed") {
+          Services.tm.dispatchToMainThread(() => {
+            resolve(false);
+          });
+        }
+        return false;
+      }
+
+      let popupOptions = {
+        hideClose: true,
+        popupIconURL: icon || DEFAULT_EXTENSION_ICON,
+        persistent: true,
+        eventCallback,
+        name: strings.addonName,
+        removeOnDismissal: true,
+      };
+
+      let action = {
+        label: strings.acceptText,
+        accessKey: strings.acceptKey,
+        callback: () => {
+          resolve(true);
+        },
+      };
+      let secondaryActions = [
+        {
+          label: strings.cancelText,
+          accessKey: strings.cancelKey,
+          callback: () => {
+            resolve(false);
+          },
+        },
+      ];
+
+      showNotification(browser, "addon-webext-permissions", strings.header,
+                       "addons-notification-icon", action, secondaryActions, popupOptions);
+    });
+
+    this.pendingNotifications.set(window, promise);
+    promise.finally(() => this.pendingNotifications.delete(window));
+    return promise;
+  },
+
+  showInstallNotification(browser, addon) {
+    let window = browser.ownerGlobal;
+
+    let brandBundle = window.document.getElementById("bundle_brand");
+    let appName = brandBundle.getString("brandShortName");
+
+    let message = addonsBundle.getFormattedString("addonPostInstall.message1",
+                                                  ["<>", appName]);
+    return new Promise(resolve => {
+      let action = {
+        label: addonsBundle.getString("addonPostInstall.okay.label"),
+        accessKey: addonsBundle.getString("addonPostInstall.okay.key"),
+        callback: resolve,
+      };
+
+      let icon = DEFAULT_EXTENSION_ICON;
+      if (addon.isWebExtension) {
+        icon = AddonManager.getPreferredIconURL(addon, 32, window) || icon;
+      }
+
+      let options = {
+        hideClose: true,
+        timeout: Date.now() + 30000,
+        popupIconURL: icon,
+        eventCallback(topic) {
+          if (topic == "dismissed") {
+            resolve();
+          }
+        },
+        name: addon.name,
+      };
+
+      showNotification(browser, "addon-installed", message, "addons-notification-icon",
+                       action, null, options);
+    });
+  },
+
+  /* eslint-disable complexity */
   observe(subject, topic, data) {
     let installInfo = subject.wrappedJSObject;
-    let browser = installInfo.browser;
+    let browser = installInfo.browser || installInfo.target;
     let window = browser.ownerGlobal;
 
     const anchorID = "addons-notification-icon";
     var messageString, action;
     var brandShortName = brandBundle.getString("brandShortName");
 
     var notificationID = topic;
     // Make notifications persistent
@@ -384,45 +507,126 @@ var gXPInstallObserver = {
             }, securityDelay - downloadDuration);
             break;
           }
         }
         showNotification();
         break;
       }
       case "addon-install-complete": {
-        let secondaryActions = null;
-        let numAddons = installInfo.installs.length;
+        this.showInstallNotification(browser, installInfo.installs[0].addon);
+        break;
+      }
+      case "webextension-permission-prompt": {
+        let {info} = subject.wrappedJSObject;
+
+        // Dismiss the progress notification.  Note that this is bad if
+        // there are multiple simultaneous installs happening, see
+        // bug 1329884 for a longer explanation.
+        let progressNotification = getNotification("addon-progress", browser);
+        if (progressNotification) {
+          progressNotification.remove();
+        }
+
+        info.unsigned = info.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING;
+        if (info.unsigned && Cu.isInAutomation &&
+            Services.prefs.getBoolPref("extensions.ui.ignoreUnsigned", false)) {
+          info.unsigned = false;
+        }
+
+        let strings = this._buildStrings(info);
+
+        // If this is an update with no promptable permissions, just apply it
+        if (info.type == "update" && strings.msgs.length == 0) {
+          info.resolve();
+          return;
+        }
+
+        let icon = info.unsigned ? "chrome://global/skin/icons/warning.svg" : info.icon;
 
-        if (numAddons == 1) {
-          messageString = addonsBundle.getFormattedString("addonInstalled",
-                                                          [installInfo.installs[0].name]);
-        } else {
-          messageString = addonsBundle.getString("addonsGenericInstalled");
-          messageString = PluralForm.get(numAddons, messageString);
-          messageString = messageString.replace("#1", numAddons);
+        this.showPermissionsPrompt(browser, strings, icon).then(answer => {
+          if (answer) {
+            info.resolve();
+          } else {
+            info.reject();
+          }
+        });
+        break;
+      }
+      case "webextension-update-permissions": {
+        let {info} = subject.wrappedJSObject;
+        info.type = "update";
+        let strings = this._buildStrings(info);
+
+        // If we don't prompt for any new permissions, just apply it.
+        if (strings.msgs.length == 0) {
+          info.resolve();
         }
-        action = null;
 
-        options.removeOnDismissal = true;
-        options.persistent = false;
+        this.showPermissionsPrompt(browser, strings, info.addon.iconURL).then(answer => {
+          if (answer) {
+            info.resolve();
+          } else {
+            info.reject();
+          }
+        });
+        break;
+      }
+      case "webextension-install-notify": {
+        let {addon, callback} = subject.wrappedJSObject;
+        this.showInstallNotification(browser, addon).then(() => {
+          if (callback) {
+            callback();
+          }
+        });
+        break;
+      }
+      case "webextension-optional-permission-prompt": {
+        let {name, icon, permissions, resolve} = subject.wrappedJSObject;
+        let strings = this._buildStrings({
+          type: "optional",
+          addon: {name},
+          permissions,
+        });
 
-        showNotification(browser, notificationID, messageString, anchorID,
-                         action, secondaryActions, options);
+        // If we don't have any promptable permissions, just proceed
+        if (strings.msgs.length == 0) {
+          resolve(true);
+          return;
+        }
+        resolve(this.showPermissionsPrompt(browser, strings, icon));
         break;
       }
     }
   },
+  /* eslint-enable complexity */
+
+  // Create a set of formatted strings for a permission prompt
+  _buildStrings(info) {
+    // This bundle isn't the same as addonsBundle.
+    let bundle = Services.strings.createBundle(ADDONS_PROPERTIES);
+    let appName = brandBundle.getString("brandShortName");
+    let info2 = Object.assign({appName}, info);
+
+    let strings = ExtensionData.formatPermissionStrings(info2, bundle);
+    strings.addonName = info.addon.name;
+    return strings;
+  },
+
   _removeProgressNotification(browser) {
     let notification = getNotification("addon-progress", browser);
     if (notification) {
       notification.remove();
     }
   },
 };
 
 Services.obs.addObserver(gXPInstallObserver, "addon-install-disabled");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-origin-blocked");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-blocked");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-started");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-failed");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-confirmation");
 Services.obs.addObserver(gXPInstallObserver, "addon-install-complete");
+Services.obs.addObserver(gXPInstallObserver, "webextension-permission-prompt");
+Services.obs.addObserver(gXPInstallObserver, "webextension-update-permissions");
+Services.obs.addObserver(gXPInstallObserver, "webextension-install-notify");
+Services.obs.addObserver(gXPInstallObserver, "webextension-optional-permission-prompt");
--- a/mail/locales/en-US/chrome/messenger/addons.properties
+++ b/mail/locales/en-US/chrome/messenger/addons.properties
@@ -70,8 +70,143 @@ addonLocalInstallError-4=%2$S could not 
 addonLocalInstallError-5=This add-on could not be installed because it has not been verified.
 
 # LOCALIZATION NOTE (addonInstallErrorIncompatible):
 # %1$S is the application name, %2$S is the application version, %3$S is the add-on name
 addonInstallErrorIncompatible=%3$S could not be installed because it is not compatible with %1$S %2$S.
 
 # LOCALIZATION NOTE (addonInstallErrorBlocklisted): %S is add-on name
 addonInstallErrorBlocklisted=%S could not be installed because it has a high risk of causing stability or security problems.
+
+# LOCALIZATION NOTE (webextPerms.header)
+# This string is used as a header in the webextension permissions dialog,
+# %S is replaced with the localized name of the extension being installed.
+# See https://bug1308309.bmoattachments.org/attachment.cgi?id=8814612
+# for an example of the full dialog.
+# Note, this string will be used as raw markup. Avoid characters like <, >, &
+webextPerms.header=Add %S?
+
+webextPerms.unsignedWarning=Caution: This add-on is unverified. Malicious add-ons can steal your private information or compromise your computer. Only install this add-on if you trust the source.
+
+# LOCALIZATION NOTE (webextPerms.listIntro)
+# This string will be followed by a list of permissions requested
+# by the webextension.
+webextPerms.listIntro=It requires your permission to:
+webextPerms.add.label=Add
+webextPerms.add.accessKey=A
+webextPerms.cancel.label=Cancel
+webextPerms.cancel.accessKey=C
+
+# LOCALIZATION NOTE (webextPerms.sideloadMenuItem)
+# %1$S will be replaced with the localized name of the sideloaded add-on.
+# %2$S will be replace with the name of the application (e.g., Firefox, Nightly)
+webextPerms.sideloadMenuItem=%1$S added to %2$S
+
+# LOCALIZATION NOTE (webextPerms.sideloadHeader)
+# This string is used as a header in the webextension permissions dialog
+# when the extension is side-loaded.
+# %S is replaced with the localized name of the extension being installed.
+# Note, this string will be used as raw markup. Avoid characters like <, >, &
+webextPerms.sideloadHeader=%S added
+webextPerms.sideloadText2=Another program on your computer installed an add-on that may affect your browser. Please review this add-on’s permissions requests and choose to Enable or Cancel (to leave it disabled).
+webextPerms.sideloadTextNoPerms=Another program on your computer installed an add-on that may affect your browser. Please choose to Enable or Cancel (to leave it disabled).
+
+webextPerms.sideloadEnable.label=Enable
+webextPerms.sideloadEnable.accessKey=E
+webextPerms.sideloadCancel.label=Cancel
+webextPerms.sideloadCancel.accessKey=C
+
+# LOCALIZATION NOTE (webextPerms.updateMenuItem)
+# %S will be replaced with the localized name of the extension which
+# has been updated.
+webextPerms.updateMenuItem=%S requires new permissions
+
+# LOCALIZATION NOTE (webextPerms.updateText)
+# %S is replaced with the localized name of the updated extension.
+# Note, this string will be used as raw markup. Avoid characters like <, >, &
+webextPerms.updateText=%S has been updated. You must approve new permissions before the updated version will install. Choosing “Cancel” will maintain your current add-on version.
+
+webextPerms.updateAccept.label=Update
+webextPerms.updateAccept.accessKey=U
+
+# LOCALIZATION NOTE (webextPerms.optionalPermsHeader)
+# %S is replace with the localized name of the extension requested new
+# permissions.
+# Note, this string will be used as raw markup. Avoid characters like <, >, &
+webextPerms.optionalPermsHeader=%S requests additional permissions.
+webextPerms.optionalPermsListIntro=It wants to:
+webextPerms.optionalPermsAllow.label=Allow
+webextPerms.optionalPermsAllow.accessKey=A
+webextPerms.optionalPermsDeny.label=Deny
+webextPerms.optionalPermsDeny.accessKey=D
+
+webextPerms.description.addressBooks=Read and modify your address books and contacts
+webextPerms.description.bookmarks=Read and modify bookmarks
+webextPerms.description.browserSettings=Read and modify browser settings
+webextPerms.description.browsingData=Clear recent browsing history, cookies, and related data
+webextPerms.description.clipboardRead=Get data from the clipboard
+webextPerms.description.clipboardWrite=Input data to the clipboard
+webextPerms.description.devtools=Extend developer tools to access your data in open tabs
+webextPerms.description.dns=Access IP address and hostname information
+webextPerms.description.downloads=Download files and read and modify the browser’s download history
+webextPerms.description.downloads.open=Open files downloaded to your computer
+webextPerms.description.find=Read the text of all open tabs
+webextPerms.description.geolocation=Access your location
+webextPerms.description.history=Access browsing history
+webextPerms.description.management=Monitor extension usage and manage themes
+# LOCALIZATION NOTE (webextPerms.description.nativeMessaging)
+# %S will be replaced with the name of the application
+webextPerms.description.nativeMessaging=Exchange messages with programs other than %S
+webextPerms.description.notifications=Display notifications to you
+webextPerms.description.pkcs11=Provide cryptographic authentication services
+webextPerms.description.privacy=Read and modify privacy settings
+webextPerms.description.proxy=Control browser proxy settings
+webextPerms.description.sessions=Access recently closed tabs
+webextPerms.description.tabs=Access browser tabs
+webextPerms.description.tabHide=Hide and show browser tabs
+webextPerms.description.topSites=Access browsing history
+webextPerms.description.unlimitedStorage=Store unlimited amount of client-side data
+webextPerms.description.webNavigation=Access browser activity during navigation
+
+webextPerms.hostDescription.allUrls=Access your data for all websites
+
+# LOCALIZATION NOTE (webextPerms.hostDescription.wildcard)
+# %S will be replaced by the DNS domain for which a webextension
+# is requesting access (e.g., mozilla.org)
+webextPerms.hostDescription.wildcard=Access your data for sites in the %S domain
+
+# LOCALIZATION NOTE (webextPerms.hostDescription.tooManyWildcards):
+# Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 will be replaced by an integer indicating the number of additional
+# domains for which this webextension is requesting permission.
+webextPerms.hostDescription.tooManyWildcards=Access your data in #1 other domain;Access your data in #1 other domains
+
+# LOCALIZATION NOTE (webextPerms.hostDescription.oneSite)
+# %S will be replaced by the DNS host name for which a webextension
+# is requesting access (e.g., www.mozilla.org)
+webextPerms.hostDescription.oneSite=Access your data for %S
+
+# LOCALIZATION NOTE (webextPerms.hostDescription.tooManySites)
+# Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 will be replaced by an integer indicating the number of additional
+# hosts for which this webextension is requesting permission.
+webextPerms.hostDescription.tooManySites=Access your data on #1 other site;Access your data on #1 other sites
+
+# LOCALIZATION NOTE (webext.defaultSearch.description)
+# %1$S is replaced with the localized named of the extension that is asking to change the default search engine.
+# %2$S is replaced with the name of the current search engine
+# %3$S is replaced with the name of the new search engine
+webext.defaultSearch.description=%1$S would like to change your default search engine from %2$S to %3$S. Is that OK?
+webext.defaultSearchYes.label=Yes
+webext.defaultSearchYes.accessKey=Y
+webext.defaultSearchNo.label=No
+webext.defaultSearchNo.accessKey=N
+
+# LOCALIZATION NOTE (webext.remove.confirmation.title)
+# %S is the name of the extension which is about to be removed.
+webext.remove.confirmation.title=Remove %S
+# LOCALIZATION NOTE (webext.remove.confirmation.message)
+# %1$S is the name of the extension which is about to be removed.
+# %2$S is brandShorterName
+webext.remove.confirmation.message=Remove %1$S from %2$S?
+webext.remove.confirmation.button=Remove