--- 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;
}