Bug 1250784: Part 1 - [webext] Add support for options_ui via inline browsers in the Add-on Manager. r?Mossop draft
authorKris Maglione <maglione.k@gmail.com>
Wed, 09 Mar 2016 16:50:08 -0800
changeset 338833 f9f9c3b81b4579181b55780fb9f84d81dd6436e3
parent 338652 6009eb63149d0dcacfa659b6d84a6f1eaa898c06
child 338834 16fa88807d9048657d968ccb99e5a6b3a7f45f76
push id12585
push usermaglione.k@gmail.com
push dateThu, 10 Mar 2016 01:42:05 +0000
reviewersMossop
bugs1250784
milestone48.0a1
Bug 1250784: Part 1 - [webext] Add support for options_ui via inline browsers in the Add-on Manager. r?Mossop MozReview-Commit-ID: 9a999BJvDHD
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/schemas/manifest.json
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/content/extensions.js
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/test/browser/browser-common.ini
toolkit/mozapps/extensions/test/browser/browser_inlinesettings_browser.js
toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
toolkit/themes/shared/extensions/extensions.inc.css
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -463,44 +463,90 @@ GlobalManager = {
         inject(extension, context);
       }
       return;
     }
 
     let extension = this.extensionMap.get(id);
     let uri = contentWindow.document.documentURIObject;
     let incognito = PrivateBrowsingUtils.isContentWindowPrivate(contentWindow);
-    let context = new ExtensionPage(extension, {type: "tab", contentWindow, uri, docShell, incognito});
+
+    let browser = docShell.chromeEventHandler;
+
+    let type = "tab";
+    if (browser instanceof Ci.nsIDOMElement) {
+      if (browser.hasAttribute("webextension-view-type")) {
+        type = browser.getAttribute("webextension-view-type");
+      } else if (browser.classList.contains("inline-options-browser")) {
+        // Options pages are currently displayed inline, but in Chrome
+        // and in our UI mock-ups for a later milestone, they're
+        // pop-ups.
+        type = "popup";
+      }
+    }
+
+    let context = new ExtensionPage(extension, {type, contentWindow, uri, docShell, incognito});
     inject(extension, context);
 
     let eventHandler = docShell.chromeEventHandler;
     let listener = event => {
       if (event.target != docShell.contentViewer.DOMDocument) {
         return;
       }
       eventHandler.removeEventListener("unload", listener, true);
       context.unload();
     };
     eventHandler.addEventListener("unload", listener, true);
   },
 };
 
+// All moz-extension URIs use a machine-specific UUID rather than the
+// extension's own ID in the host component. This makes it more
+// difficult for web pages to detect whether a user has a given add-on
+// installed (by trying to load a moz-extension URI referring to a
+// web_accessible_resource from the extension). getExtensionUUID
+// returns the UUID for a given add-on ID.
+function getExtensionUUID(id) {
+  const PREF_NAME = "extensions.webextensions.uuids";
+
+  let pref = Preferences.get(PREF_NAME, "{}");
+  let map = {};
+  try {
+    map = JSON.parse(pref);
+  } catch (e) {
+    Cu.reportError(`Error parsing ${PREF_NAME}.`);
+  }
+
+  if (id in map) {
+    return map[id];
+  }
+
+  let uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
+  let uuid = uuidGenerator.generateUUID().number;
+  uuid = uuid.slice(1, -1); // Strip { and } off the UUID.
+
+  map[id] = uuid;
+  Preferences.set(PREF_NAME, JSON.stringify(map));
+  return uuid;
+}
+
 // Represents the data contained in an extension, contained either
 // in a directory or a zip file, which may or may not be installed.
 // This class implements the functionality of the Extension class,
 // primarily related to manifest parsing and localization, which is
 // useful prior to extension installation or initialization.
 //
 // No functionality of this class is guaranteed to work before
 // |readManifest| has been called, and completed.
 this.ExtensionData = function(rootURI) {
   this.rootURI = rootURI;
 
   this.manifest = null;
   this.id = null;
+  this.uuid = null;
   this.localeData = null;
   this._promiseLocales = null;
 
   this.errors = [];
 };
 
 ExtensionData.prototype = {
   builtinMessages: null,
@@ -516,16 +562,36 @@ ExtensionData.prototype = {
   },
 
   // Report an error about the extension's general packaging.
   packagingError(message) {
     this.errors.push(message);
     this.logger.error(`Loading extension '${this.id}': ${message}`);
   },
 
+  /**
+   * Returns the moz-extension: URL for the given path within this
+   * extension.
+   *
+   * Must not be called unless either the `id` or `uuid` property has
+   * already been set.
+   *
+   * @param {string} path The path portion of the URL.
+   * @returns {string}
+   */
+  getURL(path = "") {
+    if (!(this.id || this.uuid)) {
+      throw new Error("getURL may not be called before an `id` or `uuid` has been set");
+    }
+    if (!this.uuid) {
+      this.uuid = getExtensionUUID(this.id);
+    }
+    return `moz-extension://${this.uuid}/${path}`;
+  },
+
   readDirectory: Task.async(function* (path) {
     if (this.rootURI instanceof Ci.nsIFileURL) {
       let uri = NetUtil.newURI(this.rootURI.resolve("./" + path));
       let fullPath = uri.QueryInterface(Ci.nsIFileURL).file.path;
 
       let iter = new OS.File.DirectoryIterator(fullPath);
       let results = [];
 
@@ -776,63 +842,32 @@ ExtensionData.prototype = {
 
     let results = yield Promise.all(promises);
 
     this.localeData.selectedLocale = locale;
     return results[0];
   }),
 };
 
-// All moz-extension URIs use a machine-specific UUID rather than the
-// extension's own ID in the host component. This makes it more
-// difficult for web pages to detect whether a user has a given add-on
-// installed (by trying to load a moz-extension URI referring to a
-// web_accessible_resource from the extension). getExtensionUUID
-// returns the UUID for a given add-on ID.
-function getExtensionUUID(id) {
-  const PREF_NAME = "extensions.webextensions.uuids";
-
-  let pref = Preferences.get(PREF_NAME, "{}");
-  let map = {};
-  try {
-    map = JSON.parse(pref);
-  } catch (e) {
-    Cu.reportError(`Error parsing ${PREF_NAME}.`);
-  }
-
-  if (id in map) {
-    return map[id];
-  }
-
-  let uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
-  let uuid = uuidGenerator.generateUUID().number;
-  uuid = uuid.slice(1, -1); // Strip of { and } off the UUID.
-
-  map[id] = uuid;
-  Preferences.set(PREF_NAME, JSON.stringify(map));
-  return uuid;
-}
-
 // We create one instance of this class per extension. |addonData|
 // comes directly from bootstrap.js when initializing.
 this.Extension = function(addonData) {
   ExtensionData.call(this, addonData.resourceURI);
 
   this.uuid = getExtensionUUID(addonData.id);
 
   if (addonData.cleanupFile) {
     Services.obs.addObserver(this, "xpcom-shutdown", false);
     this.cleanupFile = addonData.cleanupFile || null;
     delete addonData.cleanupFile;
   }
 
   this.addonData = addonData;
   this.id = addonData.id;
-  this.baseURI = Services.io.newURI("moz-extension://" + this.uuid, null, null);
-  this.baseURI.QueryInterface(Ci.nsIURL);
+  this.baseURI = NetUtil.newURI(this.getURL("")).QueryInterface(Ci.nsIURL);
   this.principal = this.createPrincipal();
 
   this.views = new Set();
 
   this.onStartup = null;
 
   this.hasShutdown = false;
   this.onShutdown = new Set();
--- a/toolkit/components/extensions/schemas/manifest.json
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -101,16 +101,43 @@
                     "items": { "$ref": "ExtensionURL" }
                   }
                 }
               }
             ],
             "optional": true
           },
 
+          "options_ui": {
+            "type": "object",
+
+            "optional": true,
+
+            "properties": {
+              "page": { "$ref": "ExtensionURL" },
+              "browser_style": {
+                "type": "boolean",
+                "optional": true
+              },
+              "chrome_style": {
+                "type": "boolean",
+                "optional": true
+              },
+              "open_in_tab": {
+                "type": "boolean",
+                "optional": true
+              }
+            },
+
+            "additionalProperties": {
+              "type": "any",
+              "deprecated": "An unexpected property was found in the WebExtension manifest"
+            }
+          },
+
           "content_scripts": {
             "type": "array",
             "optional": true,
             "items": { "$ref": "ContentScript" }
           },
 
           "permissions": {
             "type": "array",
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -3065,16 +3065,20 @@ this.AddonManager = {
   OPTIONS_TYPE_DIALOG: 1,
   // Options will be displayed within the AM detail view
   OPTIONS_TYPE_INLINE: 2,
   // Options will be displayed in a new tab, if possible
   OPTIONS_TYPE_TAB: 3,
   // Same as OPTIONS_TYPE_INLINE, but no Preferences button will be shown.
   // Used to indicate that only non-interactive information will be shown.
   OPTIONS_TYPE_INLINE_INFO: 4,
+  // Similar to OPTIONS_TYPE_INLINE, but rather than generating inline
+  // options from a specially-formatted XUL file, the contents of the
+  // file are simply displayed in an inline <browser> element.
+  OPTIONS_TYPE_INLINE_BROWSER: 5,
 
   // Constants for displayed or hidden options notifications
   // Options notification will be displayed
   OPTIONS_NOTIFICATION_DISPLAYED: "addon-options-displayed",
   // Options notification will be hidden
   OPTIONS_NOTIFICATION_HIDDEN: "addon-options-hidden",
 
   // Constants for getStartupChanges, addStartupChange and removeStartupChange
--- a/toolkit/mozapps/extensions/content/extensions.js
+++ b/toolkit/mozapps/extensions/content/extensions.js
@@ -1070,25 +1070,26 @@ var gViewController = {
     cmd_showItemPreferences: {
       isEnabled: function(aAddon) {
         if (!aAddon ||
             (!aAddon.isActive && !aAddon.isGMPlugin) ||
             !aAddon.optionsURL) {
           return false;
         }
         if (gViewController.currentViewObj == gDetailView &&
-            aAddon.optionsType == AddonManager.OPTIONS_TYPE_INLINE) {
+            (aAddon.optionsType == AddonManager.OPTIONS_TYPE_INLINE ||
+             aAddon.optionsType == AddonManager.OPTIONS_TYPE_INLINE_BROWSER)) {
           return false;
         }
         if (aAddon.optionsType == AddonManager.OPTIONS_TYPE_INLINE_INFO)
           return false;
         return true;
       },
       doCommand: function(aAddon) {
-        if (aAddon.optionsType == AddonManager.OPTIONS_TYPE_INLINE) {
+        if (hasInlineOptions(aAddon)) {
           gViewController.commands.cmd_showItemDetails.doCommand(aAddon, true);
           return;
         }
         var optionsURL = aAddon.optionsURL;
         if (aAddon.optionsType == AddonManager.OPTIONS_TYPE_TAB &&
             openOptionsInTab(optionsURL)) {
           return;
         }
@@ -1450,16 +1451,17 @@ var gViewController = {
     cmd.doCommand(aAddon);
   },
 
   onEvent: function() {}
 };
 
 function hasInlineOptions(aAddon) {
   return (aAddon.optionsType == AddonManager.OPTIONS_TYPE_INLINE ||
+          aAddon.optionsType == AddonManager.OPTIONS_TYPE_INLINE_BROWSER ||
           aAddon.optionsType == AddonManager.OPTIONS_TYPE_INLINE_INFO);
 }
 
 function openOptionsInTab(optionsURL) {
   let mainWindow = getMainWindow();
   if ("switchToTabHavingURI" in mainWindow) {
     mainWindow.switchToTabHavingURI(optionsURL, true);
     return true;
@@ -3302,16 +3304,44 @@ var gDetailView = {
   fillSettingsRows: function(aScrollToPreferences, aCallback) {
     this.emptySettingsRows();
     if (!hasInlineOptions(this._addon)) {
       if (aCallback)
         aCallback();
       return;
     }
 
+    // We can't use a promise for this, since some code (especially in tests)
+    // relies on us finishing before the ViewChanged event bubbles up to its
+    // listeners, and promises resolve asynchronously.
+    let whenViewLoaded = callback => {
+      if (gViewController.viewPort.selectedPanel.hasAttribute("loading")) {
+        gDetailView.node.addEventListener("ViewChanged", function viewChangedEventListener() {
+          gDetailView.node.removeEventListener("ViewChanged", viewChangedEventListener);
+          callback();
+        });
+      } else {
+        callback();
+      }
+    };
+
+    let finish = (firstSetting) => {
+      // Ensure the page has loaded and force the XBL bindings to be synchronously applied,
+      // then notify observers.
+      whenViewLoaded(() => {
+        if (firstSetting)
+          firstSetting.clientTop;
+        Services.obs.notifyObservers(document,
+                                     AddonManager.OPTIONS_NOTIFICATION_DISPLAYED,
+                                     this._addon.id);
+        if (aScrollToPreferences)
+          gDetailView.scrollToPreferencesRows();
+      });
+    }
+
     // This function removes and returns the text content of aNode without
     // removing any child elements. Removing the text nodes ensures any XBL
     // bindings apply properly.
     function stripTextNodes(aNode) {
       var text = '';
       for (var i = 0; i < aNode.childNodes.length; i++) {
         if (aNode.childNodes[i].nodeType != document.ELEMENT_NODE) {
           text += aNode.childNodes[i].textContent;
@@ -3321,81 +3351,79 @@ var gDetailView = {
         }
       }
       return text;
     }
 
     var rows = document.getElementById("detail-downloads").parentNode;
 
     try {
-      var xhr = new XMLHttpRequest();
-      xhr.open("GET", this._addon.optionsURL, true);
-      xhr.responseType = "xml";
-      xhr.onload = (function() {
-        var xml = xhr.responseXML;
-        var settings = xml.querySelectorAll(":root > setting");
-
-        var firstSetting = null;
-        for (var setting of settings) {
-
-          var desc = stripTextNodes(setting).trim();
-          if (!setting.hasAttribute("desc"))
-            setting.setAttribute("desc", desc);
-
-          var type = setting.getAttribute("type");
-          if (type == "file" || type == "directory")
-            setting.setAttribute("fullpath", "true");
-
-          setting = document.importNode(setting, true);
-          var style = setting.getAttribute("style");
-          if (style) {
-            setting.removeAttribute("style");
-            setting.setAttribute("style", style);
-          }
-
-          rows.appendChild(setting);
-          var visible = window.getComputedStyle(setting, null).getPropertyValue("display") != "none";
-          if (!firstSetting && visible) {
-            setting.setAttribute("first-row", true);
-            firstSetting = setting;
-          }
-        }
-
-        // Ensure the page has loaded and force the XBL bindings to be synchronously applied,
-        // then notify observers.
-        if (gViewController.viewPort.selectedPanel.hasAttribute("loading")) {
-          gDetailView.node.addEventListener("ViewChanged", function viewChangedEventListener() {
-            gDetailView.node.removeEventListener("ViewChanged", viewChangedEventListener, false);
-            if (firstSetting)
-              firstSetting.clientTop;
-            Services.obs.notifyObservers(document,
-                                         AddonManager.OPTIONS_NOTIFICATION_DISPLAYED,
-                                         gDetailView._addon.id);
-            if (aScrollToPreferences)
-              gDetailView.scrollToPreferencesRows();
-          }, false);
-        } else {
-          if (firstSetting)
-            firstSetting.clientTop;
-          Services.obs.notifyObservers(document,
-                                       AddonManager.OPTIONS_NOTIFICATION_DISPLAYED,
-                                       this._addon.id);
-          if (aScrollToPreferences)
-            gDetailView.scrollToPreferencesRows();
-        }
+      if (this._addon.optionsType == AddonManager.OPTIONS_TYPE_INLINE_BROWSER) {
+        whenViewLoaded(() => {
+          this.createOptionsBrowser(rows).then(browser => {
+            // Make sure the browser is unloaded as soon as we change views,
+            // rather than waiting for the next detail view to load.
+            document.addEventListener("ViewChanged", function viewChangedEventListener() {
+              document.removeEventListener("ViewChanged", viewChangedEventListener);
+              browser.remove();
+            });
+
+            finish(browser);
+          });
+        });
+
         if (aCallback)
           aCallback();
-      }).bind(this);
-      xhr.onerror = function(aEvent) {
-        Cu.reportError("Error " + aEvent.target.status +
-                       " occurred while receiving " + this._addon.optionsURL);
-        if (aCallback)
-          aCallback();
-      };
-      xhr.send();
+      } else {
+        var xhr = new XMLHttpRequest();
+        xhr.open("GET", this._addon.optionsURL, true);
+        xhr.responseType = "xml";
+        xhr.onload = (function() {
+          var xml = xhr.responseXML;
+          var settings = xml.querySelectorAll(":root > setting");
+
+          var firstSetting = null;
+          for (var setting of settings) {
+
+            var desc = stripTextNodes(setting).trim();
+            if (!setting.hasAttribute("desc"))
+              setting.setAttribute("desc", desc);
+
+            var type = setting.getAttribute("type");
+            if (type == "file" || type == "directory")
+              setting.setAttribute("fullpath", "true");
+
+            setting = document.importNode(setting, true);
+            var style = setting.getAttribute("style");
+            if (style) {
+              setting.removeAttribute("style");
+              setting.setAttribute("style", style);
+            }
+
+            rows.appendChild(setting);
+            var visible = window.getComputedStyle(setting, null).getPropertyValue("display") != "none";
+            if (!firstSetting && visible) {
+              setting.setAttribute("first-row", true);
+              firstSetting = setting;
+            }
+          }
+
+          finish(firstSetting);
+
+          if (aCallback)
+            aCallback();
+        }).bind(this);
+        xhr.onerror = function(aEvent) {
+          Cu.reportError("Error " + aEvent.target.status +
+                         " occurred while receiving " + this._addon.optionsURL);
+          if (aCallback)
+            aCallback();
+        };
+        xhr.send();
+      }
     } catch(e) {
       Cu.reportError(e);
       if (aCallback)
         aCallback();
     }
   },
 
   scrollToPreferencesRows: function() {
@@ -3408,16 +3436,89 @@ var gDetailView = {
 
       let detailViewBoxObject = gDetailView.node.boxObject;
       top -= detailViewBoxObject.y;
 
       detailViewBoxObject.scrollTo(0, top);
     }
   },
 
+  createOptionsBrowser: function(parentNode) {
+    let browser = document.createElement("browser");
+    browser.setAttribute("type", "content");
+    browser.setAttribute("disableglobalhistory", "true");
+    browser.setAttribute("class", "inline-options-browser");
+
+    // Resize at most 10 times per second.
+    const TIMEOUT = 100;
+    let timeout;
+
+    function resizeBrowser() {
+      if (timeout == null) {
+        _resizeBrowser();
+        timeout = setTimeout(_resizeBrowser, TIMEOUT);
+      }
+    }
+
+    function _resizeBrowser() {
+      timeout = null;
+
+      let doc = browser.contentDocument;
+      let body = doc.body || doc.documentElement;
+
+      let docHeight = doc.documentElement.getBoundingClientRect().height;
+
+      let height = Math.ceil(body.scrollHeight +
+                             // Compensate for any offsets between the scroll
+                             // area of the body and the outer height of the
+                             // document.
+                             docHeight - body.clientHeight);
+
+      // Note: This will trigger another MozScrolledAreaChanged event
+      // if it's different from the previous height.
+      browser.style.height = `${height}px`;
+    }
+
+    return new Promise((resolve, reject) => {
+      let onload = () => {
+        browser.removeEventListener("load", onload, true);
+
+        browser.addEventListener("error", reject);
+        browser.addEventListener("load", event => {
+          // We only get load events targetted at one of these elements.
+          // If we're running in a tab, it's the <browser>. If we're
+          // running in a dialog, it's the content document.
+          if (event.target != browser && event.target != browser.contentDocument)
+            return;
+
+          resolve(browser);
+
+          browser.contentWindow.addEventListener("MozScrolledAreaChanged", event => {
+            resizeBrowser();
+          }, true);
+
+          new browser.contentWindow.MutationObserver(resizeBrowser).observe(
+            browser.contentDocument.documentElement, {
+              attributes: true,
+              characterData: true,
+              childList: true,
+              subtree: true,
+            });
+
+          resizeBrowser();
+        }, true);
+
+        browser.setAttribute("src", this._addon.optionsURL);
+      };
+      browser.addEventListener("load", onload, true);
+
+      parentNode.appendChild(browser);
+    });
+  },
+
   getSelectedAddon: function() {
     return this._addon;
   },
 
   onEnabling: function() {
     this.updateState();
   },
 
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -885,16 +885,24 @@ var loadManifestFromWebManifest = Task.a
   addon.multiprocessCompatible = true;
   addon.internalName = null;
   addon.updateURL = manifest.applications.gecko.update_url;
   addon.updateKey = null;
   addon.optionsURL = null;
   addon.optionsType = null;
   addon.aboutURL = null;
 
+  if (manifest.options_ui) {
+    addon.optionsURL = extension.getURL(manifest.options_ui.page);
+    if (manifest.options_ui.open_in_tab)
+      addon.optionsType = AddonManager.OPTIONS_TYPE_TAB;
+    else
+      addon.optionsType = AddonManager.OPTIONS_TYPE_INLINE_BROWSER;
+  }
+
   // WebExtensions don't use iconURLs
   addon.iconURL = null;
   addon.icon64URL = null;
   addon.icons = manifest.icons || {};
 
   addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
 
   function getLocale(aLocale) {
@@ -6914,16 +6922,17 @@ AddonWrapper.prototype = {
 
     if (addon.optionsType) {
       switch (parseInt(addon.optionsType, 10)) {
       case AddonManager.OPTIONS_TYPE_DIALOG:
       case AddonManager.OPTIONS_TYPE_TAB:
         return hasOptionsURL ? addon.optionsType : null;
       case AddonManager.OPTIONS_TYPE_INLINE:
       case AddonManager.OPTIONS_TYPE_INLINE_INFO:
+      case AddonManager.OPTIONS_TYPE_INLINE_BROWSER:
         return (hasOptionsXUL || hasOptionsURL) ? addon.optionsType : null;
       }
       return null;
     }
 
     if (hasOptionsXUL)
       return AddonManager.OPTIONS_TYPE_INLINE;
 
--- a/toolkit/mozapps/extensions/test/browser/browser-common.ini
+++ b/toolkit/mozapps/extensions/test/browser/browser-common.ini
@@ -55,15 +55,16 @@ skip-if = buildapp == 'mulet'
 [browser_eula.js]
 skip-if = buildapp == 'mulet'
 [browser_updateid.js]
 [browser_purchase.js]
 [browser_openDialog.js]
 skip-if = os == 'win' && !debug # Disabled on Windows opt/PGO builds due to intermittent failures (bug 1135866)
 [browser_types.js]
 [browser_inlinesettings.js]
+[browser_inlinesettings_browser.js]
 [browser_inlinesettings_custom.js]
 [browser_inlinesettings_info.js]
 [browser_tabsettings.js]
 [browser_pluginprefs.js]
 skip-if = buildapp == 'mulet'
 [browser_CTP_plugins.js]
 skip-if = buildapp == 'mulet' || e10s
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_inlinesettings_browser.js
@@ -0,0 +1,203 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* globals TestUtils */
+
+var {Extension} = Components.utils.import("resource://gre/modules/Extension.jsm", {});
+
+var gAddon;
+var gOtherAddon;
+var gManagerWindow;
+var gCategoryUtilities;
+
+var installedAddons = [];
+
+function installAddon(details) {
+  let id = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator)
+                                              .generateUUID().number;
+  let xpi = Extension.generateXPI(id, details);
+
+  return AddonManager.installTemporaryAddon(xpi).then(addon => {
+    SimpleTest.registerCleanupFunction(function() {
+      addon.uninstall();
+
+      Services.obs.notifyObservers(xpi, "flush-cache-entry", null);
+      xpi.remove(false);
+    });
+
+    return addon;
+  });
+}
+
+add_task(function*() {
+  gAddon = yield installAddon({
+    manifest: {
+      "options_ui": {
+        "page": "options.html",
+      }
+    },
+
+    files: {
+      "options.html": `<!DOCTYPE html>
+        <html>
+          <head>
+            <meta charset="UTF-8">
+            <style type="text/css">
+              body > p {
+                height: 300px;
+                margin: 0;
+              }
+              body.bigger > p {
+                height: 600px;
+              }
+            </style>
+          </head>
+          <body>
+            <p>The quick mauve fox jumps over the opalescent dog.</p>
+          </body>
+        </html>`,
+    },
+  });
+
+  // Create another add-on with no inline options, to verify that detail
+  // view switches work correctly.
+  gOtherAddon = yield installAddon({});
+
+  gManagerWindow = yield open_manager("addons://list/extension");
+  gCategoryUtilities = new CategoryUtilities(gManagerWindow);
+});
+
+
+function* openDetailsBrowser(addonId) {
+  var addon = get_addon_element(gManagerWindow, addonId);
+
+  is(addon.mAddon.optionsType, AddonManager.OPTIONS_TYPE_INLINE_BROWSER,
+     "Options should be inline browser type");
+
+  addon.parentNode.ensureElementIsVisible(addon);
+
+  var button = gManagerWindow.document.getAnonymousElementByAttribute(addon, "anonid", "preferences-btn");
+
+  is_element_visible(button, "Preferences button should be visible");
+
+  EventUtils.synthesizeMouseAtCenter(button, { clickCount: 1 }, gManagerWindow);
+
+  yield TestUtils.topicObserved(AddonManager.OPTIONS_NOTIFICATION_DISPLAYED,
+                                (subject, data) => data == addonId);
+
+  is(gManagerWindow.gViewController.currentViewId,
+     `addons://detail/${encodeURIComponent(addonId)}/preferences`,
+     "Current view should scroll to preferences");
+
+  var browser = gManagerWindow.document.querySelector(
+    "#detail-grid > rows > .inline-options-browser");
+  var rows = browser.parentNode;
+
+  ok(browser, "Grid should have a browser child");
+  is(browser.localName, "browser", "Grid should have a browser child");
+  is(browser.currentURI.spec, addon.mAddon.optionsURL, "Browser has the expected options URL loaded")
+
+  is(browser.clientWidth, rows.clientWidth,
+     "Browser should be the same width as its parent node");
+
+  button = gManagerWindow.document.getElementById("detail-prefs-btn");
+  is_element_hidden(button, "Preferences button should not be visible");
+
+  return browser;
+}
+
+
+add_task(function* test_inline_browser_addon() {
+  let browser = yield openDetailsBrowser(gAddon.id);
+
+  let body = browser.contentDocument.body;
+
+  function checkHeights(expected) {
+    is(body.clientHeight, expected, `Document body should be ${expected}px tall`);
+    is(body.clientHeight, body.scrollHeight,
+       "Document body should be tall enough to fit its contents");
+
+    let heightDiff = browser.clientHeight - expected;
+    ok(heightDiff >= 0 && heightDiff < 50,
+       "Browser should be slightly taller than the document body");
+  }
+
+  // Delay long enough to avoid hitting our resize rate limit.
+  let delay = () => new Promise(resolve => setTimeout(resolve, 300));
+
+  checkHeights(300);
+
+  info("Increase the document height, and expect the browser to grow correspondingly");
+  body.classList.toggle("bigger");
+
+  yield delay();
+
+  checkHeights(600);
+
+  info("Decrease the document height, and expect the browser to shrink correspondingly");
+  body.classList.toggle("bigger");
+
+  yield delay();
+
+  checkHeights(300);
+
+  yield new Promise(resolve =>
+    gCategoryUtilities.openType("extension", resolve));
+
+  browser = gManagerWindow.document.querySelector(
+    ".inline-options-browser");
+
+  is(browser, null, "Options browser should be removed from the document");
+});
+
+
+// Test that loading an add-on with no inline browser works as expected
+// after having viewed our main test add-on.
+add_task(function* test_plain_addon() {
+  var addon = get_addon_element(gManagerWindow, gOtherAddon.id);
+
+  is(addon.mAddon.optionsType, null, "Add-on should have no options");
+
+  addon.parentNode.ensureElementIsVisible(addon);
+
+  yield EventUtils.synthesizeMouseAtCenter(addon, { clickCount: 1 }, gManagerWindow);
+
+  EventUtils.synthesizeMouseAtCenter(addon, { clickCount: 2 }, gManagerWindow);
+
+  yield BrowserTestUtils.waitForEvent(gManagerWindow, "ViewChanged");
+
+  is(gManagerWindow.gViewController.currentViewId,
+     `addons://detail/${encodeURIComponent(gOtherAddon.id)}`,
+     "Detail view should be open");
+
+  var browser = gManagerWindow.document.querySelector(
+    "#detail-grid > rows > .inline-options-browser");
+
+  is(browser, null, "Detail view should have no inline browser");
+
+  yield new Promise(resolve =>
+    gCategoryUtilities.openType("extension", resolve));
+});
+
+
+// Test that loading the original add-on details successfully creates a
+// browser.
+add_task(function* test_inline_browser_addon_again() {
+  let browser = yield openDetailsBrowser(gAddon.id);
+
+  yield new Promise(resolve =>
+    gCategoryUtilities.openType("extension", resolve));
+
+  browser = gManagerWindow.document.querySelector(
+    ".inline-options-browser");
+
+  is(browser, null, "Options browser should be removed from the document");
+});
+
+add_task(function*() {
+  yield close_manager(gManagerWindow);
+
+  gManagerWindow = null;
+  gCategoryUtilities = null;
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
@@ -20,16 +20,27 @@ function promiseAddonStartup() {
       Management.off("startup", listener);
       resolve(extension);
     };
 
     Management.on("startup", listener);
   });
 }
 
+function promiseInstallWebExtension(aData) {
+  let addonFile = createTempWebExtensionFile(aData);
+
+  return promiseInstallAllFiles([addonFile]).then(() => {
+    Services.obs.notifyObservers(addonFile, "flush-cache-entry", null);
+    return promiseAddonStartup();
+  }).then(() => {
+    return promiseAddonByID(aData.id);
+  });
+}
+
 add_task(function*() {
   do_check_eq(GlobalManager.count, 0);
   do_check_false(GlobalManager.extensionMap.has(ID));
 
   yield Promise.all([
     promiseInstallAllFiles([do_get_addon("webextension_1")], true),
     promiseAddonStartup()
   ]);
@@ -258,8 +269,46 @@ add_task(function*() {
   do_check_false(first_addon.isSystem);
 
   let manifestjson_id= "last-webextension2@tests.mozilla.org";
   let last_addon = yield promiseAddonByID(manifestjson_id);
   do_check_eq(last_addon, null);
 
   yield promiseRestartManager();
 });
+
+// Test that the "options_ui" manifest section is processed correctly.
+add_task(function* test_options_ui() {
+  let OPTIONS_RE = /^moz-extension:\/\/[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}\/options\.html$/;
+
+  let addon = yield promiseInstallWebExtension({
+    manifest: {
+      "options_ui": {
+        "page": "options.html",
+      },
+    },
+  });
+
+  equal(addon.optionsType, AddonManager.OPTIONS_TYPE_INLINE_BROWSER,
+        "Addon should have an INLINE_BROWSER options type");
+
+  ok(OPTIONS_RE.test(addon.optionsURL),
+     "Addon should have a moz-extension: options URL for /options.html");
+
+  addon.uninstall();
+
+  addon = yield promiseInstallWebExtension({
+    manifest: {
+      "options_ui": {
+        "page": "options.html",
+        "open_in_tab": true,
+      },
+    },
+  });
+
+  equal(addon.optionsType, AddonManager.OPTIONS_TYPE_TAB,
+        "Addon should have a TAB options type");
+
+  ok(OPTIONS_RE.test(addon.optionsURL),
+     "Addon should have a moz-extension: options URL for /options.html");
+
+  addon.uninstall();
+});
--- a/toolkit/themes/shared/extensions/extensions.inc.css
+++ b/toolkit/themes/shared/extensions/extensions.inc.css
@@ -798,16 +798,17 @@ setting {
   line-height: 20px;
   text-shadow: 0 1px 1px #fefffe;
 }
 
 #detail-controls {
   margin-bottom: 1em;
 }
 
+.inline-options-browser,
 setting[first-row="true"] {
   margin-top: 2em;
 }
 
 setting {
   -moz-box-align: start;
 }