Bug 1532724 - Part 1: Inline options browser for HTML about:addons details r=rpl,kmag a=jcristau
authorMark Striemer <mstriemer@mozilla.com>
Fri, 31 May 2019 14:05:53 +0000
changeset 536774 341cf2990acb03ec601d64db134bcf3243961cd4
parent 536773 46f0e514450cd34e77ecb0c337cda47305a6273e
child 536775 ec06ba3131eeb34a1c9a8e7753ebcbb389e28879
push id2082
push userffxbld-merge
push dateMon, 01 Jul 2019 08:34:18 +0000
treeherdermozilla-release@2fb19d0466d2 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrpl, kmag, jcristau
bugs1532724
milestone68.0
Bug 1532724 - Part 1: Inline options browser for HTML about:addons details r=rpl,kmag a=jcristau Differential Revision: https://phabricator.services.mozilla.com/D29787
dom/base/nsFrameLoader.cpp
modules/libpref/init/all.js
toolkit/mozapps/extensions/content/aboutaddons.css
toolkit/mozapps/extensions/content/aboutaddons.html
toolkit/mozapps/extensions/content/aboutaddons.js
toolkit/mozapps/extensions/content/aboutaddonsCommon.js
toolkit/mozapps/extensions/content/extensions.js
toolkit/mozapps/extensions/test/browser/browser.ini
toolkit/mozapps/extensions/test/browser/browser_html_options_ui.js
--- a/dom/base/nsFrameLoader.cpp
+++ b/dom/base/nsFrameLoader.cpp
@@ -2662,21 +2662,26 @@ bool nsFrameLoader::TryRemoteBrowser() {
       // window.parent will be different than they would be for a non-remote
       // frame.
       nsCOMPtr<nsIWebNavigation> parentWebNav;
       nsCOMPtr<nsIURI> aboutAddons;
       nsCOMPtr<nsIURI> parentURI;
       bool equals;
       if (!((parentWebNav = do_GetInterface(parentDocShell)) &&
             NS_SUCCEEDED(
-                NS_NewURI(getter_AddRefs(aboutAddons), "about:addons")) &&
-            NS_SUCCEEDED(
                 parentWebNav->GetCurrentURI(getter_AddRefs(parentURI))) &&
-            NS_SUCCEEDED(parentURI->EqualsExceptRef(aboutAddons, &equals)) &&
-            equals)) {
+            ((NS_SUCCEEDED(
+                  NS_NewURI(getter_AddRefs(aboutAddons), "about:addons")) &&
+              NS_SUCCEEDED(parentURI->EqualsExceptRef(aboutAddons, &equals)) &&
+              equals) ||
+             (NS_SUCCEEDED(NS_NewURI(
+                  getter_AddRefs(aboutAddons),
+                  "chrome://mozapps/content/extensions/aboutaddons.html")) &&
+              NS_SUCCEEDED(parentURI->EqualsExceptRef(aboutAddons, &equals)) &&
+              equals)))) {
         return false;
       }
     }
 
     if (!mOwnerContent->IsXULElement()) {
       return false;
     }
 
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -5190,17 +5190,17 @@ pref("extensions.webextensions.enablePer
 // Maximum age in milliseconds of performance counters in children
 // When reached, the counters are sent to the main process and
 // reset, so we reduce memory footprint.
 pref("extensions.webextensions.performanceCountersMaxAge", 5000);
 
 // The HTML about:addons page.
 pref("extensions.htmlaboutaddons.enabled", false);
 // Whether to allow the inline options browser in HTML about:addons page.
-pref("extensions.htmlaboutaddons.inline-options.enabled", false);
+pref("extensions.htmlaboutaddons.inline-options.enabled", true);
 
 // Report Site Issue button
 // Note that on enabling the button in other release channels, make sure to
 // disable it in problematic tests, see disableNonReleaseActions() inside
 // browser/modules/test/browser/head.js
 pref("extensions.webcompat-reporter.newIssueEndpoint", "https://webcompat.com/issues/new");
 #if MOZ_UPDATE_CHANNEL != release && MOZ_UPDATE_CHANNEL != esr
 pref("extensions.webcompat-reporter.enabled", true);
--- a/toolkit/mozapps/extensions/content/aboutaddons.css
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -357,16 +357,23 @@ panel-item-separator[hidden] {
   text-decoration: underline;
 }
 
 .button-link:active {
   color: var(--in-content-link-color-active) !important;
   text-decoration: none;
 }
 
+.inline-options-stack {
+  /* If the options browser triggers an alert we need room to show it. */
+  min-height: 250px;
+  width: 100%;
+  background-color: white;
+}
+
 addon-permissions-list > .addon-detail-row:first-of-type {
   border-top: none;
 }
 
 .deck-tab-group {
   border-bottom: 1px solid var(--grey-90-a20);
   border-top: 1px solid var(--grey-90-a20);
   margin-top: 8px;
--- a/toolkit/mozapps/extensions/content/aboutaddons.html
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -1,29 +1,36 @@
 <!DOCTYPE html>
 <html>
   <head>
+    <link rel="stylesheet" href="chrome://global/content/tabprompts.css">
+    <link rel="stylesheet" href="chrome://global/skin/tabprompts.css">
+
     <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
     <link rel="stylesheet" href="chrome://mozapps/content/extensions/aboutaddons.css">
 
     <link rel="localization" href="branding/brand.ftl">
     <link rel="localization" href="toolkit/about/aboutAddons.ftl">
     <link rel="localization" href="toolkit/about/abuseReports.ftl">
 
     <script src="chrome://mozapps/content/extensions/named-deck.js"></script>
     <script src="chrome://mozapps/content/extensions/aboutaddonsCommon.js"></script>
     <script src="chrome://mozapps/content/extensions/message-bar.js"></script>
     <script src="chrome://mozapps/content/extensions/abuse-reports.js"></script>
     <script src="chrome://mozapps/content/extensions/aboutaddons.js"></script>
   </head>
   <body>
     <message-bar-stack id="abuse-reports-messages" reverse max-message-bar-count="3">
     </message-bar-stack>
-    <div id="main">
-    </div>
+
+    <div id="main"></div>
+
+    <!-- Include helpers for the inline options browser select and context menus. -->
+    <content-select-dropdown></content-select-dropdown>
+    <proxy-context-menu id="contentAreaContextMenu"></proxy-context-menu>
 
     <template name="addon-options">
       <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>
         <panel-item data-l10n-id="preferences-addon-button" action="preferences"></panel-item>
         <panel-item-separator></panel-item-separator>
@@ -84,16 +91,17 @@
         <five-star-rating></five-star-rating>
         <span class="disco-user-count"></span>
       </div>
     </template>
 
     <template name="addon-details">
       <div class="deck-tab-group">
         <named-deck-button deck="details-deck" name="details" data-l10n-id="details-addon-button"></named-deck-button>
+        <named-deck-button deck="details-deck" name="preferences" data-l10n-id="preferences-addon-button"></named-deck-button>
         <named-deck-button deck="details-deck" name="permissions" data-l10n-id="permissions-addon-button"></named-deck-button>
         <named-deck-button deck="details-deck" name="release-notes" data-l10n-id="release-notes-addon-button"></named-deck-button>
       </div>
       <named-deck id="details-deck">
         <section name="details">
           <div class="addon-detail-description"></div>
           <div class="addon-detail-contribute">
             <label data-l10n-id="detail-contributions-description"></label>
@@ -154,16 +162,17 @@
           <div class="addon-detail-row addon-detail-row-rating">
             <label data-l10n-id="addon-detail-rating-label"></label>
             <div class="addon-detail-rating">
               <five-star-rating></five-star-rating>
               <a target="_blank"></a>
             </div>
           </div>
         </section>
+        <inline-options-browser name="preferences"></inline-options-browser>
         <addon-permissions-list name="permissions"></addon-permissions-list>
         <update-release-notes name="release-notes"></update-release-notes>
       </named-deck>
     </template>
 
     <template name="five-star-rating">
       <link rel="stylesheet" href="chrome://mozapps/content/extensions/rating-star.css">
       <span class="rating-star"></span>
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -1,36 +1,44 @@
 /* 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/. */
 /* eslint max-len: ["error", 80] */
 /* exported initialize, hide, show */
 /* import-globals-from aboutaddonsCommon.js */
 /* import-globals-from abuse-reports.js */
-/* global windowRoot */
+/* global MozXULElement, windowRoot */
 
 "use strict";
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
   AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
   AMTelemetry: "resource://gre/modules/AddonManager.jsm",
   ClientID: "resource://gre/modules/ClientID.jsm",
+  DeferredTask: "resource://gre/modules/DeferredTask.jsm",
+  E10SUtils: "resource://gre/modules/E10SUtils.jsm",
+  ExtensionParent: "resource://gre/modules/ExtensionParent.jsm",
   ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
 });
 
 XPCOMUtils.defineLazyGetter(this, "browserBundle", () => {
   return Services.strings.createBundle(
     "chrome://browser/locale/browser.properties");
 });
 XPCOMUtils.defineLazyGetter(this, "brandBundle", () => {
   return Services.strings.createBundle(
       "chrome://branding/locale/brand.properties");
 });
+XPCOMUtils.defineLazyGetter(this, "extensionStylesheets", () => {
+  const {ExtensionParent} =
+    ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
+  return ExtensionParent.extensionStylesheets;
+});
 
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "allowPrivateBrowsingByDefault",
   "extensions.allowPrivateBrowsingByDefault", true);
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "SUPPORT_URL", "app.support.baseURL",
   "", null, val => Services.urlFormatter.formatURL(val));
 
@@ -582,17 +590,18 @@ class AddonOptions extends HTMLElement {
         break;
       case "install-update":
         el.hidden = !updateInstall;
         break;
       case "expand":
         el.hidden = card.expanded;
         break;
       case "preferences":
-        el.hidden = getOptionsType(addon) !== "tab";
+        el.hidden = getOptionsType(addon) !== "tab" &&
+          (getOptionsType(addon) !== "inline" || card.expanded);
         break;
     }
   }
 
   update(card, addon, updateInstall) {
     for (let el of this.querySelectorAll("panel-item")) {
       this.setElementState(el, card, addon, updateInstall);
     }
@@ -683,16 +692,218 @@ class FiveStarRating extends HTMLElement
     }
     document.l10n.setAttributes(this, "five-star-rating", {
       rating: this.rating,
     });
   }
 }
 customElements.define("five-star-rating", FiveStarRating);
 
+class ContentSelectDropdown extends HTMLElement {
+  connectedCallback() {
+    if (this.children.length > 0) {
+      return;
+    }
+    // This creates the menulist and menupopup elements needed for the inline
+    // browser to support <select> elements and context menus.
+    this.appendChild(MozXULElement.parseXULToFragment(`
+      <menulist popuponly="true" id="ContentSelectDropdown" hidden="true">
+        <menupopup rolluponmousewheel="true" activateontab="true"
+                   position="after_start" level="parent"/>
+      </menulist>
+    `));
+  }
+}
+customElements.define("content-select-dropdown", ContentSelectDropdown);
+
+class ProxyContextMenu extends HTMLElement {
+  openPopupAtScreen(...args) {
+    const parentContextMenuPopup =
+      windowRoot.ownerGlobal.document.getElementById("contentAreaContextMenu");
+    return parentContextMenuPopup.openPopupAtScreen(...args);
+  }
+}
+customElements.define("proxy-context-menu", ProxyContextMenu);
+
+class InlineOptionsBrowser extends HTMLElement {
+  constructor() {
+    super();
+    // Force the options_ui remote browser to recompute window.mozInnerScreenX
+    // and window.mozInnerScreenY when the "addon details" page has been
+    // scrolled (See Bug 1390445 for rationale).
+    // Also force a repaint to fix an issue where the click location was
+    // getting out of sync (see bug 1548687).
+    this.updatePositionTask = new DeferredTask(() => {
+      if (this.browser && this.browser.isRemoteBrowser) {
+        // Select boxes can appear in the wrong spot after scrolling, this will
+        // clear that up. Bug 1390445.
+        this.browser.frameLoader.requestUpdatePosition();
+        // Sometimes after scrolling the inner brower needs to repaint to get
+        // the right mouse position. Force that to happen. Bug 1548687.
+        window.windowUtils.flushApzRepaints();
+      }
+    }, 100);
+  }
+
+  connectedCallback() {
+    window.addEventListener("scroll", this, true);
+  }
+
+  disconnectedCallback() {
+    window.removeEventListener("scroll", this, true);
+  }
+
+  handleEvent(e) {
+    if (e.type == "scroll") {
+      this.updatePositionTask.arm();
+    }
+  }
+
+  setAddon(addon) {
+    this.addon = addon;
+  }
+
+  destroyBrowser() {
+    this.textContent = "";
+  }
+
+  ensureBrowserCreated() {
+    if (this.childElementCount === 0) {
+      this.render();
+    }
+  }
+
+  async render() {
+    let {addon} = this;
+    if (!addon) {
+      throw new Error("addon required to create inline options");
+    }
+
+    let browser = document.createXULElement("browser");
+    browser.setAttribute("type", "content");
+    browser.setAttribute("disableglobalhistory", "true");
+    browser.setAttribute("id", "addon-inline-options");
+    browser.setAttribute("transparent", "true");
+    browser.setAttribute("forcemessagemanager", "true");
+    browser.setAttribute("selectmenulist", "ContentSelectDropdown");
+    browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+
+    // The outer about:addons document listens for key presses to focus
+    // the search box when / is pressed.  But if we're focused inside an
+    // options page, don't let those keypresses steal focus.
+    browser.addEventListener("keypress", event => {
+      event.stopPropagation();
+    });
+
+    let {optionsURL, optionsBrowserStyle} = addon;
+    if (addon.isWebExtension) {
+      let policy = ExtensionParent.WebExtensionPolicy.getByID(addon.id);
+      browser.sameProcessAsFrameLoader = policy.extension.groupFrameLoader;
+    }
+
+    let readyPromise;
+    let remoteSubframes =
+      window.docShell.QueryInterface(Ci.nsILoadContext).useRemoteSubframes;
+    let loadRemote = E10SUtils.canLoadURIInRemoteType(
+      optionsURL, remoteSubframes, E10SUtils.EXTENSION_REMOTE_TYPE);
+    if (loadRemote) {
+      browser.setAttribute("remote", "true");
+      browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE);
+
+      readyPromise = promiseEvent("XULFrameLoaderCreated", browser);
+
+      readyPromise.then(() => {
+        if (!browser.messageManager) {
+          // Early exit if the the extension page's XUL browser has been
+          // destroyed in the meantime (e.g. because the extension has been
+          // reloaded while the options page was still loading).
+          return;
+        }
+
+        // Subscribe a "contextmenu" listener to handle the context menus for
+        // the extension option page running in the extension process (the
+        // context menu will be handled only for extension running in OOP mode,
+        // but that's ok as it is the default on any platform that uses these
+        // extensions options pages).
+        browser.messageManager.addMessageListener("contextmenu", message => {
+          windowRoot.ownerGlobal.openContextMenu(message);
+        });
+      });
+    } else {
+      readyPromise = promiseEvent("load", browser, true);
+    }
+
+    let stack = document.createXULElement("stack");
+    stack.classList.add("inline-options-stack");
+    stack.appendChild(browser);
+    this.appendChild(stack);
+    this.browser = browser;
+
+    // Force bindings to apply synchronously.
+    browser.clientTop;
+
+    await readyPromise;
+
+    if (!browser.messageManager) {
+      // If the browser.messageManager is undefined, the browser element has
+      // been removed from the document in the meantime (e.g. due to a rapid
+      // sequence of addon reload), return null.
+      return;
+    }
+
+    ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+
+    await new Promise(resolve => {
+      let messageListener = {
+        receiveMessage({name, data}) {
+          if (name === "Extension:BrowserResized") {
+            // Add a pixel to work around a scrolling issue, bug 1548687.
+            browser.style.height = `${data.height + 1}px`;
+          } else if (name === "Extension:BrowserContentLoaded") {
+            resolve();
+          }
+        },
+      };
+
+      let mm = browser.messageManager;
+
+      if (!mm) {
+        // If the browser.messageManager is undefined, the browser element has
+        // been removed from the document in the meantime (e.g. due to a rapid
+        // sequence of addon reload), return null.
+        resolve();
+        return;
+      }
+
+      mm.loadFrameScript("chrome://extensions/content/ext-browser-content.js",
+                         false, true);
+      mm.loadFrameScript("chrome://browser/content/content.js", false, true);
+      mm.addMessageListener("Extension:BrowserContentLoaded", messageListener);
+      mm.addMessageListener("Extension:BrowserResized", messageListener);
+
+      let browserOptions = {
+        fixedWidth: true,
+        isInline: true,
+      };
+
+      if (optionsBrowserStyle) {
+        browserOptions.stylesheets = extensionStylesheets;
+      }
+
+      mm.sendAsyncMessage("Extension:InitBrowser", browserOptions);
+
+      browser.loadURI(optionsURL, {
+        triggeringPrincipal:
+          Services.scriptSecurityManager.getSystemPrincipal(),
+      });
+    });
+  }
+}
+customElements.define("inline-options-browser", InlineOptionsBrowser);
+
 class UpdateReleaseNotes extends HTMLElement {
   connectedCallback() {
     this.addEventListener("click", this);
   }
 
   disconnectedCallback() {
     this.removeEventListener("click", this);
   }
@@ -805,27 +1016,35 @@ class AddonDetails extends HTMLElement {
   connectedCallback() {
     if (this.children.length == 0) {
       this.render();
     }
     this.deck.addEventListener("view-changed", this);
   }
 
   disconnectedCallback() {
+    this.inlineOptions.destroyBrowser();
     this.deck.removeEventListener("view-changed", this);
   }
 
   handleEvent(e) {
     if (e.type == "view-changed" && e.target == this.deck) {
-      if (this.deck.selectedViewName == "release-notes") {
-        let releaseNotes = this.querySelector("update-release-notes");
-        let uri = this.releaseNotesUri;
-        if (uri) {
-          releaseNotes.loadForUri(uri);
-        }
+      switch (this.deck.selectedViewName) {
+        case "release-notes":
+          let releaseNotes = this.querySelector("update-release-notes");
+          let uri = this.releaseNotesUri;
+          if (uri) {
+            releaseNotes.loadForUri(uri);
+          }
+          break;
+        case "preferences":
+          if (getOptionsType(this.addon) == "inline") {
+            this.inlineOptions.ensureBrowserCreated();
+          }
+          break;
       }
     }
   }
 
   get releaseNotesUri() {
     return this.addon.updateInstall ?
       this.addon.updateInstall.releaseNotesURI : this.addon.releaseNotesURI;
   }
@@ -839,16 +1058,18 @@ class AddonDetails extends HTMLElement {
 
     // Hide tab buttons that won't have any content.
     let getButtonByName =
       name => this.tabGroup.querySelector(`[name="${name}"]`);
     let permsBtn = getButtonByName("permissions");
     permsBtn.hidden = addon.type != "extension";
     let notesBtn = getButtonByName("release-notes");
     notesBtn.hidden = !this.releaseNotesUri;
+    let prefsBtn = getButtonByName("preferences");
+    prefsBtn.hidden = getOptionsType(addon) !== "inline";
 
     // Hide the tab group if "details" is the only visible button.
     this.tabGroup.hidden = Array.from(this.tabGroup.children).every(button => {
       return button.name == "details" || button.hidden;
     });
 
     // Show the update check button if necessary. The button might not exist if
     // the add-on doesn't support updates.
@@ -876,16 +1097,20 @@ class AddonDetails extends HTMLElement {
 
     this.deck = this.querySelector("named-deck");
     this.tabGroup = this.querySelector(".deck-tab-group");
 
     // Set the add-on for the permissions section.
     this.permissionsList = this.querySelector("addon-permissions-list");
     this.permissionsList.setAddon(addon);
 
+    // Set the add-on for the preferences section.
+    this.inlineOptions = this.querySelector("inline-options-browser");
+    this.inlineOptions.setAddon(addon);
+
     // Full description.
     let description = this.querySelector(".addon-detail-description");
     if (addon.getFullDescription) {
       description.appendChild(addon.getFullDescription(document));
     } else if (addon.fullDescription) {
       description.appendChild(nl2br(addon.fullDescription));
     }
 
@@ -972,16 +1197,23 @@ class AddonDetails extends HTMLElement {
         numberOfReviews: addon.reviewCount,
       });
     } else {
       ratingRow.remove();
     }
 
     this.update();
   }
+
+  showPrefs() {
+    if (getOptionsType(this.addon) == "inline") {
+      this.deck.selectedViewName = "preferences";
+      this.inlineOptions.ensureBrowserCreated();
+    }
+  }
 }
 customElements.define("addon-details", AddonDetails);
 
 /**
  * A card component for managing an add-on. It should be initialized by setting
  * the add-on with `setAddon()` before being connected to the document.
  *
  *    let card = document.createElement("addon-card");
@@ -1115,16 +1347,18 @@ class AddonCard extends HTMLElement {
           windowRoot.ownerGlobal.openUILinkIn(addon.contributionURL, "tab", {
             triggeringPrincipal:
               Services.scriptSecurityManager.createNullPrincipal({}),
           });
           break;
         case "preferences":
           if (getOptionsType(addon) == "tab") {
             openOptionsInTab(addon.optionsURL);
+          } else if (getOptionsType(addon) == "inline") {
+            loadViewFn("detail", this.addon.id, "preferences");
           }
           break;
         case "remove":
           {
             this.panel.hide();
             let {
               remove, report,
             } = windowRoot.ownerGlobal.promptRemoveExtension(addon);
@@ -1311,16 +1545,20 @@ class AddonCard extends HTMLElement {
     // Update the details if they're shown.
     if (this.details) {
       this.details.update();
     }
 
     this.sendEvent("update");
   }
 
+  showPrefs() {
+    this.details.showPrefs();
+  }
+
   expand() {
     if (this.children.length == 0) {
       this.expanded = true;
     } else {
       throw new Error("expand() is only supported before render()");
     }
   }
 
@@ -2068,17 +2306,19 @@ class ListView {
     await list.render();
     this.root.textContent = "";
     this.root.appendChild(list);
   }
 }
 
 class DetailView {
   constructor({param, root}) {
-    this.id = param;
+    let [id, selectedTab] = param.split("/");
+    this.id = id;
+    this.selectedTab = selectedTab;
     this.root = root;
   }
 
   async render() {
     let addon = await AddonManager.getAddonByID(this.id);
 
     if (!addon) {
       replaceWithDefaultViewFn();
@@ -2091,16 +2331,19 @@ class DetailView {
     setCategoryFn(addon.type);
 
     // Go back to the list view when the add-on is removed.
     card.addEventListener("remove", () => loadViewFn("list", addon.type));
 
     card.setAddon(addon);
     card.expand();
     await card.render();
+    if (this.selectedTab === "preferences") {
+      card.showPrefs();
+    }
 
     this.root.textContent = "";
     this.root.appendChild(card);
   }
 }
 
 class UpdatesView {
   constructor({param, root}) {
--- a/toolkit/mozapps/extensions/content/aboutaddonsCommon.js
+++ b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js
@@ -1,33 +1,39 @@
 /* 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/. */
 /* eslint max-len: ["error", 80] */
 
 "use strict";
 
-/* exported attachUpdateHandler, getBrowserElement, loadReleaseNotes,
-            openOptionsInTab, shouldShowPermissionsPrompt,
+/* exported attachUpdateHandler, gBrowser, getBrowserElement, loadReleaseNotes,
+            openOptionsInTab, promiseEvent, shouldShowPermissionsPrompt,
             showPermissionsPrompt */
 
 var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
 var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyPreferenceGetter(
   this, "WEBEXT_PERMISSION_PROMPTS",
   "extensions.webextPermissionPrompts", false);
 
 ChromeUtils.defineModuleGetter(this, "Extension",
                                "resource://gre/modules/Extension.jsm");
 
 function getBrowserElement() {
   return window.docShell.chromeEventHandler;
 }
 
+function promiseEvent(event, target, capture = false) {
+  return new Promise(resolve => {
+    target.addEventListener(event, resolve, {capture, once: true});
+  });
+}
+
 function attachUpdateHandler(install) {
   if (!WEBEXT_PERMISSION_PROMPTS) {
     return;
   }
 
   install.promptHandler = (info) => {
     let oldPerms = info.existingAddon.userPermissions;
     if (!oldPerms) {
@@ -144,8 +150,23 @@ function showPermissionsPrompt(addon) {
             // Ignore a cancelled permission prompt.
           },
         },
       },
     };
     Services.obs.notifyObservers(subject, "webextension-permission-prompt");
   });
 }
+
+// Stub tabbrowser implementation for use by the tab-modal alert code
+// when an alert/prompt/confirm method is called in a WebExtensions options_ui
+// page (See Bug 1385548 for rationale).
+var gBrowser = {
+  getTabModalPromptBox(browser) {
+    const parentWindow = window.docShell.chromeEventHandler.ownerGlobal;
+
+    if (parentWindow.gBrowser) {
+      return parentWindow.gBrowser.getTabModalPromptBox(browser);
+    }
+
+    return null;
+  },
+};
--- a/toolkit/mozapps/extensions/content/extensions.js
+++ b/toolkit/mozapps/extensions/content/extensions.js
@@ -93,22 +93,16 @@ XPCOMUtils.defineLazyPreferenceGetter(th
                                       raw => raw.split(","));
 XPCOMUtils.defineLazyPreferenceGetter(this, "legacyExtensionsEnabled",
                                       PREF_LEGACY_ENABLED, true,
                                       () => gLegacyView.refreshVisibility());
 
 document.addEventListener("load", initialize, true);
 window.addEventListener("unload", shutdown);
 
-function promiseEvent(event, target, capture = false) {
-  return new Promise(resolve => {
-    target.addEventListener(event, resolve, {capture, once: true});
-  });
-}
-
 var gPendingInitializations = 1;
 Object.defineProperty(this, "gIsInitializing", {
   get: () => gPendingInitializations > 0,
 });
 
 function initialize(event) {
   // XXXbz this listener gets _all_ load events for all nodes in the
   // document... but relies on not being called "too early".
@@ -3770,31 +3764,16 @@ var gDragDrop = {
           },
         });
         AddonManager.installAddonFromAOM(browser, document.documentURIObject, install);
       }
     }
   },
 };
 
-// Stub tabbrowser implementation for use by the tab-modal alert code
-// when an alert/prompt/confirm method is called in a WebExtensions options_ui page
-// (See Bug 1385548 for rationale).
-var gBrowser = {
-  getTabModalPromptBox(browser) {
-    const parentWindow = window.docShell.chromeEventHandler.ownerGlobal;
-
-    if (parentWindow.gBrowser) {
-      return parentWindow.gBrowser.getTabModalPromptBox(browser);
-    }
-
-    return null;
-  },
-};
-
 // Force the options_ui remote browser to recompute window.mozInnerScreenX and
 // window.mozInnerScreenY when the "addon details" page has been scrolled
 // (See Bug 1390445 for rationale).
 {
   const UPDATE_POSITION_DELAY = 100;
 
   const updatePositionTask = new DeferredTask(() => {
     const browser = document.getElementById("addon-options");
@@ -3807,21 +3786,26 @@ var gBrowser = {
     updatePositionTask.arm();
   }, true);
 }
 
 const addonTypes = new Set([
   "extension", "theme", "plugin", "dictionary", "locale",
 ]);
 const htmlViewOpts = {
-  loadViewFn(type, param) {
-    let viewId = `addons://${type}/`;
-    if (param) {
-      viewId += encodeURIComponent(param);
+  loadViewFn(type, ...params) {
+    let viewId = `addons://${type}`;
+    if (params.length > 0) {
+      for (let param of params) {
+        viewId += "/" + encodeURIComponent(param);
+      }
+    } else {
+      viewId += "/";
     }
+
     gViewController.loadView(viewId);
   },
   replaceWithDefaultViewFn() {
     gViewController.replaceView(gViewDefault);
   },
   setCategoryFn(name) {
     if (addonTypes.has(name)) {
       gCategories.select(`addons://list/${name}`);
--- a/toolkit/mozapps/extensions/test/browser/browser.ini
+++ b/toolkit/mozapps/extensions/test/browser/browser.ini
@@ -81,16 +81,17 @@ skip-if = os == 'linux' && !debug # Bug 
 [browser_html_abuse_report.js]
 [browser_html_detail_view.js]
 [browser_html_discover_view.js]
 [browser_html_discover_view_clientid.js]
 [browser_html_discover_view_prefs.js]
 [browser_html_list_view.js]
 [browser_html_message_bar.js]
 [browser_html_named_deck.js]
+[browser_html_options_ui.js]
 [browser_html_options_ui_in_tab.js]
 [browser_html_plugins.js]
 skip-if = (os == 'win' && processor == 'aarch64') # aarch64 has no plugin support, bug 1525174 and 1547495
 [browser_html_recent_updates.js]
 [browser_html_updates.js]
 [browser_inlinesettings_browser.js]
 skip-if = os == 'mac' || os == 'linux' # Bug 1483347
 [browser_installssl.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_options_ui.js
@@ -0,0 +1,250 @@
+/* eslint max-len: ["error", 80] */
+
+const {AddonTestUtils} =
+  ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
+const {ExtensionParent} =
+  ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm");
+
+AddonTestUtils.initMochitest(this);
+
+// This test function helps to detect when an addon options browser have been
+// inserted in the about:addons page.
+function waitOptionsBrowserInserted() {
+  return new Promise(resolve => {
+    async function listener(eventName, browser) {
+      // wait for a webextension XUL browser element that is owned by the
+      // "about:addons" page.
+      if (browser.ownerGlobal.top.location.href == "about:addons") {
+        ExtensionParent.apiManager.off("extension-browser-inserted", listener);
+        resolve(browser);
+      }
+    }
+    ExtensionParent.apiManager.on("extension-browser-inserted", listener);
+  });
+}
+
+add_task(async function enableHtmlViews() {
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      ["extensions.htmlaboutaddons.enabled", true],
+      ["extensions.htmlaboutaddons.inline-options.enabled", true],
+    ],
+  });
+});
+
+add_task(async function testInlineOptions() {
+  const HEIGHT_SHORT = 300;
+  const HEIGHT_TALL = 600;
+
+  let id = "inline@mochi.test";
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      applications: {gecko: {id}},
+      options_ui: {
+        page: "options.html",
+      },
+    },
+    files: {
+      "options.html": `
+        <html>
+          <head>
+            <style type="text/css">
+              body > p { height: ${HEIGHT_SHORT}px; margin: 0; }
+              body.bigger > p { height: ${HEIGHT_TALL}px; }
+            </style>
+            <script src="options.js"></script>
+          </head>
+          <body>
+            <p>Some text</p>
+          </body>
+        </html>
+      `,
+      "options.js": () => {
+        browser.test.onMessage.addListener(msg => {
+          if (msg == "toggle-class") {
+            document.body.classList.toggle("bigger");
+          } else if (msg == "get-height") {
+            browser.test.sendMessage("height", document.body.clientHeight);
+          }
+        });
+
+        browser.test.sendMessage("options-loaded", window.location.href);
+      },
+    },
+    useAddonManager: "temporary",
+  });
+  await extension.startup();
+
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+
+  // Make sure we found the right card.
+  let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+  ok(card, "Found the card");
+
+  // The preferences option should be visible.
+  let preferences = card.querySelector('[action="preferences"]');
+  ok(!preferences.hidden, "The preferences option is visible");
+
+  // Open the preferences page.
+  let loaded = waitForViewLoad(win);
+  preferences.click();
+  await loaded;
+
+  // Verify we're on the preferences tab.
+  card = doc.querySelector("addon-card");
+  is(card.addon.id, id, "The right page was loaded");
+  let {deck, tabGroup} = card.details;
+  let {selectedViewName} = deck;
+  is(selectedViewName, "preferences", "The preferences tab is shown");
+
+  info("Check that there are two buttons and they're visible");
+  let detailsBtn = tabGroup.querySelector('[name="details"]');
+  ok(!detailsBtn.hidden, "The details button is visible");
+  let prefsBtn = tabGroup.querySelector('[name="preferences"]');
+  ok(!prefsBtn.hidden, "The preferences button is visible");
+
+  // Wait for the browser to load.
+  let url = await extension.awaitMessage("options-loaded");
+
+  // Check the attributes of the options browser.
+  let browser = card.querySelector("inline-options-browser browser");
+  ok(browser, "The visible view has a browser");
+  is(browser.currentURI.spec, card.addon.optionsURL,
+     "The browser has the expected options URL");
+  is(url, card.addon.optionsURL, "Browser has the expected options URL loaded");
+  let stack = browser.closest("stack");
+  is(browser.clientWidth, stack.clientWidth,
+     "Browser should be the same width as its direct parent");
+  ok(stack.clientWidth > 0, "The stack has a width");
+  ok(card.querySelector('[action="preferences"]').hidden,
+     "The preferences option is hidden now");
+
+  let lastHeight;
+  let waitForHeightChange = () => TestUtils.waitForCondition(() => {
+    if (browser.clientHeight !== lastHeight) {
+      lastHeight = browser.clientHeight;
+      return true;
+    }
+    return false;
+  });
+
+  // The expected heights are 1px taller, to work around bug 1548687.
+  const EXPECTED_HEIGHT_SHORT = HEIGHT_SHORT + 1;
+  const EXPECTED_HEIGHT_TALL = HEIGHT_TALL + 1;
+
+  await waitForHeightChange();
+
+  // Check resizing the browser through extension CSS.
+  await extension.sendMessage("get-height");
+  let height = await extension.awaitMessage("height");
+  is(height, EXPECTED_HEIGHT_SHORT, "The height is smaller to start");
+  is(height, browser.clientHeight, "The browser is the same size");
+
+  info("Resize the browser to be taller");
+  await extension.sendMessage("toggle-class");
+  await waitForHeightChange();
+  await extension.sendMessage("get-height");
+  height = await extension.awaitMessage("height");
+  is(height, EXPECTED_HEIGHT_TALL, "The height is bigger now");
+  is(height, browser.clientHeight, "The browser is the same size");
+
+  info("Shrink the browser again");
+  await extension.sendMessage("toggle-class");
+  await waitForHeightChange();
+  await extension.sendMessage("get-height");
+  height = await extension.awaitMessage("height");
+  is(height, EXPECTED_HEIGHT_SHORT, "The browser shrunk back");
+  is(height, browser.clientHeight, "The browser is the same size");
+
+  info("Switching to details view");
+  detailsBtn.click();
+
+  info("Check the browser dimensions to make sure it's hidden");
+  is(browser.clientWidth, 0, "The browser is hidden now");
+
+  info("Switch back, check browser is shown");
+  prefsBtn.click();
+
+  is(browser.clientWidth, stack.clientWidth, "The browser width is set again");
+  ok(stack.clientWidth > 0, "The stack has a width");
+
+  await closeView(win);
+  await extension.unload();
+});
+
+add_task(async function testCardRerender() {
+  let id = "rerender@mochi.test";
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      applications: {gecko: {id}},
+      options_ui: {
+        page: "options.html",
+      },
+    },
+    files: {
+      "options.html": `
+        <html>
+          <body>
+            <p>Some text</p>
+          </body>
+        </html>
+      `,
+    },
+    useAddonManager: "permanent",
+  });
+  await extension.startup();
+
+  let win = await loadInitialView("extension");
+  let doc = win.document;
+
+  let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+  let loaded = waitForViewLoad(win);
+  card.querySelector('[action="expand"]').click();
+  await loaded;
+
+  card = doc.querySelector("addon-card");
+
+  let browserAdded = waitOptionsBrowserInserted();
+  card.querySelector('named-deck-button[name="preferences"]').click();
+  await browserAdded;
+
+  is(doc.querySelectorAll("inline-options-browser").length, 1,
+     "There is 1 inline-options-browser");
+  is(doc.querySelectorAll("browser").length, 1, "There is 1 browser");
+
+  info("Reload the add-on and ensure there's still only one browser");
+  let updated = BrowserTestUtils.waitForEvent(card, "update", () => {
+    // Wait for the update when the add-on name isn't the disabled text.
+    return !card.querySelector(".addon-name").hasAttribute("data-l10n-name");
+  });
+  card.addon.reload();
+  await updated;
+
+  is(doc.querySelectorAll("inline-options-browser").length, 1,
+     "There is 1 inline-options-browser");
+  is(doc.querySelectorAll("browser").length, 1, "There is 1 browser");
+
+  info("Re-rendering card to ensure a second browser isn't added");
+  updated = BrowserTestUtils.waitForEvent(card, "update");
+  card.render();
+  await updated;
+
+  is(card.details.deck.selectedViewName, "details",
+     "Rendering reverted to the details view");
+
+  is(doc.querySelectorAll("inline-options-browser").length, 1,
+     "There is still only 1 inline-options-browser after re-render");
+  is(doc.querySelectorAll("browser").length, 0, "There is no browser");
+
+  let newBrowserAdded = waitOptionsBrowserInserted();
+  card.showPrefs();
+  await newBrowserAdded;
+
+  is(doc.querySelectorAll("inline-options-browser").length, 1,
+     "There is still only 1 inline-options-browser after opening preferences");
+  is(doc.querySelectorAll("browser").length, 1, "There is 1 browser");
+
+  await closeView(win);
+  await extension.unload();
+});