Bug 1535683 - Part 2: Private browsing in HTML about:addons r=mixedpuppy,flod,jaws
authorMark Striemer <mstriemer@mozilla.com>
Sat, 27 Apr 2019 00:30:41 +0000
changeset 530429 edceab8a8ace306709f93fee5aec3fc70dae3a74
parent 530428 e20e1f19947b455a24d15ba82eee9fd262c6428d
child 530430 2fd7f8b748612b07075365154685cadf6f0cb29d
push id11265
push userffxbld-merge
push dateMon, 13 May 2019 10:53:39 +0000
treeherdermozilla-beta@77e0fe8dbdd3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmixedpuppy, flod, jaws
bugs1535683
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1535683 - Part 2: Private browsing in HTML about:addons r=mixedpuppy,flod,jaws Differential Revision: https://phabricator.services.mozilla.com/D27138
toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
toolkit/mozapps/extensions/content/aboutaddons.css
toolkit/mozapps/extensions/content/aboutaddons.html
toolkit/mozapps/extensions/content/aboutaddons.js
toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
--- a/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
+++ b/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
@@ -373,8 +373,16 @@ addon-detail-reviews-link =
     }
 
 addon-detail-updates-label = Allow automatic updates
 addon-detail-updates-radio-default = Default
 addon-detail-updates-radio-on = On
 addon-detail-updates-radio-off = Off
 addon-detail-update-check-label = Check for Updates
 install-update-button = Update
+
+# This is the tooltip text for the private browsing badge in about:addons. The
+# badge is the private browsing icon included next to the extension's name.
+addon-badge-private-browsing-allowed =
+    .title = Allowed in private windows
+addon-detail-private-browsing-help = When allowed, the extension will have access to your online activities while private browsing. <a data-l10n-name="learn-more">Learn more</a>
+addon-detail-private-browsing-allow = Allow
+addon-detail-private-browsing-disallow = Don’t Allow
--- a/toolkit/mozapps/extensions/content/aboutaddons.css
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -62,22 +62,38 @@ addon-list .addon.card {
   display: flex;
   flex-direction: column;
 }
 
 .card-actions {
   flex-shrink: 0;
 }
 
+.addon-name-container {
+  /* Subtract the top line-height so the text and icon align at the top. */
+  margin-top: -3px;
+  display: flex;
+  align-items: center;
+}
+
 .addon-name {
   font-size: 16px;
   font-weight: 600;
   line-height: 22px;
-  /* Subtract the top line-height so the text and icon align at the top. */
-  margin-top: -3px;
+  margin-inline-end: 8px;
+}
+
+.addon-badge {
+  width: 16px;
+  height: 16px;
+  background-size: 16px;
+}
+
+.addon-badge-private-browsing-allowed {
+  background-image: url("chrome://browser/skin/private-browsing.svg");
 }
 
 .addon-description {
   font-size: 14px;
   line-height: 20px;
   color: var(--in-content-deemphasized-text);
   font-weight: 400;
 }
@@ -131,28 +147,35 @@ addon-card:not([expanded]) .addon-descri
   margin-top: var(--card-padding);
   margin-bottom: 0;
   align-self: flex-end;
 }
 
 .addon-detail-row {
   display: flex;
   justify-content: space-between;
+  align-items: center;
   border-top: 1px solid var(--grey-90-a20);
   margin: 0 calc(var(--card-padding) * -1);
   padding: var(--card-padding);
+  color: var(--grey-90);
 }
 
+.addon-detail-row-has-help,
 .addon-detail-row:last-of-type {
   padding-bottom: 0;
 }
 
-.addon-detail-row {
-  -moz-context-properties: fill;
-  fill: currentColor;
+.addon-detail-row-help {
+  color: var(--grey-60);
+  padding-bottom: var(--card-padding);
+}
+
+.addon-detail-row input[type="checkbox"] {
+  margin: 0;
 }
 
 .addon-detail-rating {
   display: flex;
 }
 
 .addon-detail-rating-star {
   display: inline-block;
--- a/toolkit/mozapps/extensions/content/aboutaddons.html
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -15,17 +15,22 @@
     </div>
 
     <template name="card">
       <div class="card addon">
         <img class="card-heading-image">
         <div class="addon-card-collapsed">
           <img class="card-heading-icon addon-icon">
           <div class="card-contents">
-            <span class="addon-name"></span>
+            <div class="addon-name-container">
+              <span class="addon-name"></span>
+              <div class="addon-badge addon-badge-private-browsing-allowed"
+                    data-l10n-id="addon-badge-private-browsing-allowed"
+                    hidden></div>
+            </div>
             <span class="addon-description"></span>
           </div>
           <div class="more-options-menu">
             <button class="more-options-button ghost-button" action="more-options"></button>
             <panel-list>
               <panel-item action="toggle-disabled"></panel-item>
               <panel-item data-l10n-id="remove-addon-button" action="remove"></panel-item>
               <panel-item data-l10n-id="install-update-button" action="install-update" badged></panel-item>
@@ -61,16 +66,32 @@
             <span data-l10n-id="addon-detail-updates-radio-on"></span>
           </label>
           <label>
             <input type="radio" name="autoupdate" value="0"/>
             <span data-l10n-id="addon-detail-updates-radio-off"></span>
           </label>
         </div>
       </div>
+      <div class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing">
+        <label data-l10n-id="detail-private-browsing-label"></label>
+        <div>
+          <label>
+            <input type="radio" name="private-browsing" value="1"/>
+            <span data-l10n-id="addon-detail-private-browsing-allow"></span>
+          </label>
+          <label>
+            <input type="radio" name="private-browsing" value="0"/>
+            <span data-l10n-id="addon-detail-private-browsing-disallow"></span>
+          </label>
+        </div>
+      </div>
+      <div class="addon-detail-row-help" data-l10n-id="addon-detail-private-browsing-help">
+        <a target="_blank" data-l10n-name="learn-more"></a>
+      </div>
       <div class="addon-detail-row addon-detail-row-author">
         <label data-l10n-id="addon-detail-author-label"></label>
       </div>
       <div class="addon-detail-row addon-detail-row-version">
         <label data-l10n-id="addon-detail-version-label"></label>
       </div>
       <div class="addon-detail-row addon-detail-row-lastUpdated">
         <label data-l10n-id="addon-detail-last-updated-label"></label>
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -5,26 +5,44 @@
 /* exported initialize, hide, show */
 /* import-globals-from aboutaddonsCommon.js */
 /* global windowRoot */
 
 "use strict";
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
+  ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
 });
 
+XPCOMUtils.defineLazyPreferenceGetter(
+  this, "allowPrivateBrowsingByDefault",
+  "extensions.allowPrivateBrowsingByDefault", true);
+XPCOMUtils.defineLazyPreferenceGetter(
+  this, "SUPPORT_URL", "app.support.baseURL",
+  "", null, val => Services.urlFormatter.formatURL(val));
+
 const PLUGIN_ICON_URL = "chrome://global/skin/plugins/pluginGeneric.svg";
 const PERMISSION_MASKS = {
   enable: AddonManager.PERM_CAN_ENABLE,
   disable: AddonManager.PERM_CAN_DISABLE,
   uninstall: AddonManager.PERM_CAN_UNINSTALL,
   upgrade: AddonManager.PERM_CAN_UPGRADE,
 };
 
+const PRIVATE_BROWSING_PERM_NAME = "internal:privateBrowsingAllowed";
+const PRIVATE_BROWSING_PERMS =
+  {permissions: [PRIVATE_BROWSING_PERM_NAME], origins: []};
+
+async function isAllowedInPrivateBrowsing(addon) {
+  // Use the Promise directly so this function stays sync for the other case.
+  let perms = await ExtensionPermissions.get(addon.id);
+  return perms.permissions.includes(PRIVATE_BROWSING_PERM_NAME);
+}
+
 function hasPermission(addon, permission) {
   return !!(addon.permissions & PERMISSION_MASKS[permission]);
 }
 
 /**
  * This function is set in initialize() by the parent about:addons window. It
  * is a helper for gViewController.loadView().
  *
@@ -269,17 +287,17 @@ class AddonDetails extends HTMLElement {
 
     // Set the value for auto updates.
     let inputs = this.querySelectorAll(".addon-detail-row-updates input");
     for (let input of inputs) {
       input.checked = input.value == addon.applyBackgroundUpdates;
     }
   }
 
-  render() {
+  async render() {
     let {addon} = this;
     if (!addon) {
       throw new Error("addon-details must be initialized by setAddon");
     }
 
     this.textContent = "";
     this.appendChild(importTemplate("addon-details"));
 
@@ -296,16 +314,28 @@ class AddonDetails extends HTMLElement {
       this.querySelector(".addon-detail-contribute").remove();
     }
 
     // Auto updates setting.
     if (!hasPermission(addon, "upgrade")) {
       this.querySelector(".addon-detail-row-updates").remove();
     }
 
+    let pbRow = this.querySelector(".addon-detail-row-private-browsing");
+    if (!allowPrivateBrowsingByDefault && addon.type == "extension" &&
+        addon.incognito != "not_allowed") {
+      let isAllowed = await isAllowedInPrivateBrowsing(addon);
+      pbRow.querySelector(`[value="${isAllowed ? 1 : 0}"]`).checked = true;
+      let learnMore = pbRow.nextElementSibling
+        .querySelector('a[data-l10n-name="learn-more"]');
+      learnMore.href = SUPPORT_URL + "extensions-pb";
+    } else {
+      pbRow.remove();
+    }
+
     // Author.
     let creatorRow = this.querySelector(".addon-detail-row-author");
     if (addon.creator) {
       let creator;
       if (addon.creator.url) {
         creator = document.createElement("a");
         creator.href = addon.creator.url;
         creator.target = "_blank";
@@ -415,16 +445,28 @@ class AddonCard extends HTMLElement {
 
   set updateInstall(install) {
     this._updateInstall = install;
     if (this.children.length > 0) {
       this.update();
     }
   }
 
+  get reloading() {
+    return this.hasAttribute("reloading");
+  }
+
+  set reloading(val) {
+    if (val) {
+      this.setAttribute("reloading", "true");
+    } else {
+      this.removeAttribute("reloading");
+    }
+  }
+
   /**
    * Set the add-on for this card. The card will be populated based on the
    * add-on when it is connected to the DOM.
    *
    * @param {AddonWrapper} addon The add-on to use.
    */
   setAddon(addon) {
     this.addon = addon;
@@ -537,16 +579,37 @@ class AddonCard extends HTMLElement {
             this.panel.toggle(e);
           }
           break;
       }
     } else if (e.type == "change") {
       let {name} = e.target;
       if (name == "autoupdate") {
         addon.applyBackgroundUpdates = e.target.value;
+      } else if (name == "private-browsing") {
+        let policy = WebExtensionPolicy.getByID(addon.id);
+        let extension = policy && policy.extension;
+
+        if (e.target.value == "1") {
+          await ExtensionPermissions.add(
+            addon.id, PRIVATE_BROWSING_PERMS, extension);
+        } else {
+          await ExtensionPermissions.remove(
+            addon.id, PRIVATE_BROWSING_PERMS, extension);
+        }
+        // Reload the extension if it is already enabled. This ensures any
+        // change on the private browsing permission is properly handled.
+        if (addon.isActive) {
+          this.reloading = true;
+          // Reloading will trigger an enable and update the card.
+          addon.reload();
+        } else {
+          // Update the card if the add-on isn't active.
+          this.update();
+        }
       }
     } else if (e.type == "mousedown") {
       // Open panel on mousedown when the mouse is used.
       if (action == "more-options") {
         this.panel.toggle(e);
       }
     } else if (e.type == "dblclick") {
       // Don't expand if expanded or a button is double clicked.
@@ -587,23 +650,24 @@ class AddonCard extends HTMLElement {
 
   onInstallEnded(install) {
     if (install.addon.id == this.addon.id) {
       this.setAddon(install.addon);
     }
   }
 
   onDisabled(addon) {
-    if (addon.id == this.addon.id) {
+    if (!this.reloading && addon.id == this.addon.id) {
       this.update();
     }
   }
 
   onEnabled(addon) {
     if (addon.id == this.addon.id) {
+      this.reloading = false;
       this.update();
     }
   }
 
   onUpdateModeChanged() {
     this.update();
   }
 
@@ -648,16 +712,26 @@ class AddonCard extends HTMLElement {
       name.removeAttribute("data-l10n-id");
     } else {
       document.l10n.setAttributes(name, "addon-name-disabled", {
         name: addon.name,
       });
     }
     name.title = `${addon.name} ${addon.version}`;
 
+    // Set the private browsing badge visibility.
+    if (!allowPrivateBrowsingByDefault && addon.type == "extension" &&
+        addon.incognito != "not_allowed") {
+      // Keep update synchronous, the badge can appear later.
+      isAllowedInPrivateBrowsing(addon).then(isAllowed => {
+        card.querySelector(".addon-badge-private-browsing-allowed")
+          .hidden = !isAllowed;
+      });
+    }
+
     // Update description.
     card.querySelector(".addon-description").textContent = addon.description;
 
     // Hide remove button if not allowed.
     let removeButton = card.querySelector('[action="remove"]');
     removeButton.hidden = !hasPermission(addon, "uninstall");
 
     // Set disable label and hide if not allowed.
@@ -707,29 +781,33 @@ class AddonCard extends HTMLElement {
     }
 
     this.card = importTemplate("card").firstElementChild;
     this.setAttribute("addon-id", addon.id);
 
     // Set the contents.
     this.update();
 
+    let doneRenderPromise = Promise.resolve();
     if (this.expanded) {
       if (!this.details) {
         this.details = document.createElement("addon-details");
       }
       this.details.setAddon(this.addon);
-      this.details.render();
+      doneRenderPromise = this.details.render();
 
       // If we're re-rendering we still need to append the details since the
       // entire card was emptied at the beginning of the render.
       this.card.appendChild(this.details);
     }
 
     this.appendChild(this.card);
+
+    // Return the promise of details rendering to wait on in DetailView.
+    return doneRenderPromise;
   }
 
   sendEvent(name, detail) {
     this.dispatchEvent(new CustomEvent(name, {detail}));
   }
 }
 customElements.define("addon-card", AddonCard);
 
--- a/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
@@ -1,20 +1,30 @@
 /* eslint max-len: ["error", 80] */
 
+const {ExtensionPermissions} =
+  ChromeUtils.import("resource://gre/modules/ExtensionPermissions.jsm", {});
+
 let gProvider;
 let promptService;
 
 function getAddonCard(doc, addonId) {
   return doc.querySelector(`addon-card[addon-id="${addonId}"]`);
 }
 
 function checkLabel(row, name) {
+  let id;
+  if (name == "private-browsing") {
+    // This id is carried over from the old about:addons.
+    id = "detail-private-browsing-label";
+  } else {
+    id = `addon-detail-${name}-label`;
+  }
   is(row.ownerDocument.l10n.getAttributes(row.querySelector("label")).id,
-    `addon-detail-${name}-label`, `The ${name} label is set`);
+    id, `The ${name} label is set`);
 }
 
 function checkLink(link, url, text = url) {
   ok(link, "There is a link");
   is(link.href, url, "The link goes to the URL");
   if (text instanceof Object) {
     // Check the fluent data.
     Assert.deepEqual(
@@ -22,19 +32,44 @@ function checkLink(link, url, text = url
       text, "The fluent data is set correctly");
   } else {
     // Just check text.
     is(link.textContent, text, "The text is set");
   }
   is(link.getAttribute("target"), "_blank", "The link opens in a new tab");
 }
 
+function checkOptions(doc, options, expectedOptions) {
+  let numOptions = expectedOptions.length;
+  is(options.length, numOptions, `There are ${numOptions} options`);
+  for (let i = 0; i < numOptions; i++) {
+    let option = options[i];
+    is(option.children.length, 2, "There are 2 children for the option");
+    let input = option.firstElementChild;
+    is(input.tagName, "INPUT", "The input is first");
+    let text = option.lastElementChild;
+    is(text.tagName, "SPAN", "The label text is second");
+    let expected = expectedOptions[i];
+    is(input.value, expected.value, "The value is right");
+    is(input.checked, expected.checked, "The checked property is correct");
+    Assert.deepEqual(
+      doc.l10n.getAttributes(text), {id: expected.label},
+      "The label has the right text");
+  }
+}
+
+async function hasPrivateAllowed(id) {
+  let perms = await ExtensionPermissions.get(id);
+  return perms.permissions.includes("internal:privateBrowsingAllowed");
+}
+
 add_task(async function enableHtmlViews() {
   await SpecialPowers.pushPrefEnv({
-    set: [["extensions.htmlaboutaddons.enabled", true]],
+    set: [["extensions.htmlaboutaddons.enabled", true],
+          ["extensions.allowPrivateBrowsingByDefault", false]],
   });
 
   gProvider = new MockProvider();
   gProvider.createAddons([{
     id: "addon1@mochi.test",
     name: "Test add-on 1",
     creator: {name: "The creator", url: "http://example.com/me"},
     version: "3.1",
@@ -242,31 +277,21 @@ add_task(async function testFullDetails(
   let row = rows.shift();
   checkLabel(row, "updates");
   let expectedOptions = [
     {value: "1", label: "addon-detail-updates-radio-default", checked: false},
     {value: "2", label: "addon-detail-updates-radio-on", checked: true},
     {value: "0", label: "addon-detail-updates-radio-off", checked: false},
   ];
   let options = row.lastElementChild.querySelectorAll("label");
-  is(options.length, 3, "There are 3 options");
-  for (let i = 0; i < 3; i++) {
-    let option = options[i];
-    is(option.children.length, 2, "There are 2 children for the option");
-    let input = option.firstElementChild;
-    is(input.tagName, "INPUT", "The input is first");
-    let text = option.lastElementChild;
-    is(text.tagName, "SPAN", "The label text is second");
-    let expected = expectedOptions[i];
-    is(input.value, expected.value, "The value is right");
-    is(input.checked, expected.checked, "The checked property is correct");
-    Assert.deepEqual(
-      doc.l10n.getAttributes(text), {id: expected.label},
-      "The label has the right text");
-  }
+  checkOptions(doc, options, expectedOptions);
+
+  // Private browsing, functionality checked in another test.
+  row = rows.shift();
+  checkLabel(row, "private-browsing");
 
   // Author.
   row = rows.shift();
   checkLabel(row, "author");
   let link = row.querySelector("a");
   checkLink(link, "http://example.com/me", "The creator");
 
   // Version.
@@ -327,16 +352,19 @@ add_task(async function testMinimalExten
   ok(!contrib, "There is no contribution element");
 
   let rows = Array.from(details.querySelectorAll(".addon-detail-row"));
 
   // Automatic updates.
   let row = rows.shift();
   checkLabel(row, "updates");
 
+  row = rows.shift();
+  checkLabel(row, "private-browsing");
+
   // Author.
   row = rows.shift();
   checkLabel(row, "author");
   let text = row.lastChild;
   is(text.textContent, "I made it", "The author is set");
   ok(text instanceof Text, "The author is a text node");
 
   is(rows.length, 0, "There are no more rows");
@@ -432,8 +460,105 @@ add_task(async function testStaticTheme(
   checkLabel(author, "author");
   let text = author.lastChild;
   is(text.textContent, "Artist", "The author is set");
 
   is(rows.length, 0, "There was only 1 row");
 
   await closeView(win);
 });
+
+add_task(async function testPrivateBrowsingExtension() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      name: "My PB extension",
+      applications: {gecko: {id: "pb@mochi.test"}},
+    },
+    useAddonManager: "permanent",
+  });
+
+  await extension.startup();
+
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+
+  // The add-on shouldn't show that it's allowed yet.
+  let card = getAddonCard(doc, "pb@mochi.test");
+  let badge = card.querySelector(".addon-badge-private-browsing-allowed");
+  ok(badge.hidden, "The PB badge is hidden initially");
+  ok(!await hasPrivateAllowed("pb@mochi.test"), "PB is not allowed");
+
+  // Load the detail view.
+  let loaded = waitForViewLoad(win);
+  card.querySelector('[action="expand"]').click();
+  await loaded;
+
+  // The badge is still hidden on the detail view.
+  card = getAddonCard(doc, "pb@mochi.test");
+  badge = card.querySelector(".addon-badge-private-browsing-allowed");
+  ok(badge.hidden, "The PB badge is hidden on the detail view");
+  ok(!await hasPrivateAllowed("pb@mochi.test"), "PB is not allowed");
+
+  let pbRow = card.querySelector(".addon-detail-row-private-browsing");
+
+  // Allow private browsing.
+  let [allow, disallow] = pbRow.querySelectorAll("input");
+  let updated = BrowserTestUtils.waitForEvent(card, "update");
+  allow.click();
+  await updated;
+  ok(!badge.hidden, "The PB badge is now shown");
+  ok(await hasPrivateAllowed("pb@mochi.test"), "PB is allowed");
+
+  // Disable the add-on and change the value.
+  updated = BrowserTestUtils.waitForEvent(card, "update");
+  card.querySelector('[action="toggle-disabled"]').click();
+  await updated;
+
+  // It's still allowed in PB.
+  ok(!badge.hidden, "The PB badge is shown");
+  ok(await hasPrivateAllowed("pb@mochi.test"), "PB is allowed");
+
+  // Disallow PB.
+  updated = BrowserTestUtils.waitForEvent(card, "update");
+  disallow.click();
+  await updated;
+
+  ok(badge.hidden, "The PB badge is hidden");
+  ok(!await hasPrivateAllowed("pb@mochi.test"), "PB is disallowed");
+
+  // Allow PB.
+  updated = BrowserTestUtils.waitForEvent(card, "update");
+  allow.click();
+  await updated;
+
+  ok(!badge.hidden, "The PB badge is hidden");
+  ok(await hasPrivateAllowed("pb@mochi.test"), "PB is disallowed");
+
+  await extension.unload();
+  await closeView(win);
+});
+
+add_task(async function testPrivateBrowsingAllowedListView() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      name: "Allowed PB extension",
+      applications: {gecko: {id: "allowed@mochi.test"}},
+    },
+    useAddonManager: "permanent",
+  });
+
+  await extension.startup();
+  let perms = {permissions: ["internal:privateBrowsingAllowed"], origins: []};
+  await ExtensionPermissions.add("allowed@mochi.test", perms);
+  let addon = await AddonManager.getAddonByID("allowed@mochi.test");
+  await addon.reload();
+
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+
+  // The allowed extension should have a badge on load.
+  let card = getAddonCard(doc, "allowed@mochi.test");
+  let badge = card.querySelector(".addon-badge-private-browsing-allowed");
+  ok(!badge.hidden, "The PB badge is shown for the allowed add-on");
+
+  await extension.unload();
+  await closeView(win);
+});