Bug 1250784: Part 3 - [webext] Add suport for runtime.openOptionsPage. r=Mossop draft
authorKris Maglione <maglione.k@gmail.com>
Wed, 09 Mar 2016 17:10:29 -0800
changeset 338835 daa4356fc0b0a97a08833753381c4a1cf128166e
parent 338834 16fa88807d9048657d968ccb99e5a6b3a7f45f76
child 515863 df0aea6dbc2b8cf6a2642c98ee20983222f12aa7
push id12585
push usermaglione.k@gmail.com
push dateThu, 10 Mar 2016 01:42:05 +0000
reviewersMossop
bugs1250784
milestone48.0a1
Bug 1250784: Part 3 - [webext] Add suport for runtime.openOptionsPage. r=Mossop MozReview-Commit-ID: 9izx4uX0Szd
browser/base/content/browser.js
browser/components/extensions/ext-desktop-runtime.js
browser/components/extensions/test/browser/browser.ini
browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js
toolkit/components/extensions/.eslintrc
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/MessageChannel.jsm
toolkit/components/extensions/ext-runtime.js
toolkit/components/extensions/schemas/runtime.json
toolkit/mozapps/extensions/internal/XPIProvider.jsm
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -6270,55 +6270,61 @@ var MailIntegration = {
        Cc["@mozilla.org/uriloader/external-protocol-service;1"]
          .getService(Ci.nsIExternalProtocolService);
     if (extProtocolSvc)
       extProtocolSvc.loadUrl(aURL);
   }
 };
 
 function BrowserOpenAddonsMgr(aView) {
-  if (aView) {
-    let emWindow;
-    let browserWindow;
-
-    var receivePong = function receivePong(aSubject, aTopic, aData) {
-      let browserWin = aSubject.QueryInterface(Ci.nsIInterfaceRequestor)
-                               .getInterface(Ci.nsIWebNavigation)
-                               .QueryInterface(Ci.nsIDocShellTreeItem)
-                               .rootTreeItem
-                               .QueryInterface(Ci.nsIInterfaceRequestor)
-                               .getInterface(Ci.nsIDOMWindow);
-      if (!emWindow || browserWin == window /* favor the current window */) {
-        emWindow = aSubject;
-        browserWindow = browserWin;
+  return new Promise(resolve => {
+    if (aView) {
+      let emWindow;
+      let browserWindow;
+
+      var receivePong = function receivePong(aSubject, aTopic, aData) {
+        let browserWin = aSubject.QueryInterface(Ci.nsIInterfaceRequestor)
+                                 .getInterface(Ci.nsIWebNavigation)
+                                 .QueryInterface(Ci.nsIDocShellTreeItem)
+                                 .rootTreeItem
+                                 .QueryInterface(Ci.nsIInterfaceRequestor)
+                                 .getInterface(Ci.nsIDOMWindow);
+        if (!emWindow || browserWin == window /* favor the current window */) {
+          emWindow = aSubject;
+          browserWindow = browserWin;
+        }
       }
-    }
-    Services.obs.addObserver(receivePong, "EM-pong", false);
-    Services.obs.notifyObservers(null, "EM-ping", "");
-    Services.obs.removeObserver(receivePong, "EM-pong");
-
-    if (emWindow) {
-      emWindow.loadView(aView);
-      browserWindow.gBrowser.selectedTab =
-        browserWindow.gBrowser._getTabForContentWindow(emWindow);
-      emWindow.focus();
-      return;
-    }
-  }
-
-  var newLoad = !switchToTabHavingURI("about:addons", true);
-
-  if (aView) {
-    // This must be a new load, else the ping/pong would have
-    // found the window above.
-    Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
-      Services.obs.removeObserver(observer, aTopic);
-      aSubject.loadView(aView);
-    }, "EM-loaded", false);
-  }
+      Services.obs.addObserver(receivePong, "EM-pong", false);
+      Services.obs.notifyObservers(null, "EM-ping", "");
+      Services.obs.removeObserver(receivePong, "EM-pong");
+
+      if (emWindow) {
+        emWindow.loadView(aView);
+        browserWindow.gBrowser.selectedTab =
+          browserWindow.gBrowser._getTabForContentWindow(emWindow);
+        emWindow.focus();
+        resolve(emWindow);
+        return;
+      }
+    }
+
+    switchToTabHavingURI("about:addons", true);
+
+    if (aView) {
+      // This must be a new load, else the ping/pong would have
+      // found the window above.
+      Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+        Services.obs.removeObserver(observer, aTopic);
+        aSubject.loadView(aView);
+        resolve(aSubject);
+      }, "EM-loaded", false);
+    } else {
+      resolve();
+    }
+  });
 }
 
 function AddKeywordForSearchField() {
   let mm = gBrowser.selectedBrowser.messageManager;
 
   let onMessage = (message) => {
     mm.removeMessageListener("ContextMenu:SearchFieldBookmarkData:Result", onMessage);
 
--- a/browser/components/extensions/ext-desktop-runtime.js
+++ b/browser/components/extensions/ext-desktop-runtime.js
@@ -1,10 +1,26 @@
 "use strict";
 
 /* eslint-disable mozilla/balanced-listeners */
 extensions.on("uninstall", (msg, extension) => {
   if (extension.uninstallURL) {
-    let browser = Services.wm.getMostRecentWindow("navigator:browser").gBrowser;
+    let browser = WindowManager.topWindow.gBrowser;
     browser.addTab(extension.uninstallURL, {relatedToCurrent: true});
   }
 });
 
+global.openOptionsPage = (extension) => {
+  let window = WindowManager.topWindow;
+  if (!window) {
+    return Promise.reject({message: "No browser window available"});
+  }
+
+  if (extension.manifest.options_ui.open_in_tab) {
+    window.switchToTabHavingURI(extension.manifest.options_ui.page, true);
+    return Promise.resolve();
+  }
+
+  let viewId = `addons://detail/${encodeURIComponent(extension.id)}/preferences`;
+
+  return window.BrowserOpenAddonsMgr(viewId);
+};
+
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -22,16 +22,17 @@ support-files =
 [browser_ext_pageAction_popup.js]
 [browser_ext_browserAction_popup.js]
 [browser_ext_popup_api_injection.js]
 [browser_ext_contextMenus.js]
 [browser_ext_commands_getAll.js]
 [browser_ext_commands_onCommand.js]
 [browser_ext_getViews.js]
 [browser_ext_lastError.js]
+[browser_ext_runtime_openOptionsPage.js]
 [browser_ext_runtime_setUninstallURL.js]
 [browser_ext_tabs_audio.js]
 [browser_ext_tabs_captureVisibleTab.js]
 [browser_ext_tabs_detectLanguage.js]
 [browser_ext_tabs_events.js]
 [browser_ext_tabs_executeScript.js]
 [browser_ext_tabs_executeScript_good.js]
 [browser_ext_tabs_executeScript_bad.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js
@@ -0,0 +1,228 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function* loadExtension(options) {
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: true,
+
+    manifest: Object.assign({
+      "permissions": ["tabs"],
+    }, options.manifest),
+
+    files: {
+      "options.html": `<!DOCTYPE html>
+        <html>
+          <head>
+            <meta charset="utf-8">
+            <script src="options.js" type="text/javascript"></script>
+          </head>
+        </html>`,
+
+      "options.js": function() {
+        browser.runtime.sendMessage("options.html");
+        browser.runtime.onMessage.addListener((msg, sender, respond) => {
+          if (msg == "ping") {
+            respond("pong");
+          }
+        });
+      },
+    },
+
+    background: options.background,
+  });
+
+  yield extension.startup();
+
+  return extension;
+}
+
+add_task(function* test_inline_options() {
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/");
+
+  let extension = yield loadExtension({
+    manifest: {
+      "options_ui": {
+        "page": "options.html",
+      },
+    },
+
+    background: function() {
+      let _optionsPromise;
+      let awaitOptions = () => {
+        browser.test.assertFalse(_optionsPromise, "Should not be awaiting options already");
+
+        return new Promise(resolve => {
+          _optionsPromise = {resolve};
+        });
+      };
+
+      browser.runtime.onMessage.addListener((msg, sender) => {
+        if (msg == "options.html") {
+          if (_optionsPromise) {
+            _optionsPromise.resolve(sender.tab);
+            _optionsPromise = null;
+          } else {
+            browser.test.fail("Saw unexpected options page load");
+          }
+        }
+      });
+
+      let firstTab, optionsTab;
+      browser.tabs.query({currentWindow: true, active: true}).then(tabs => {
+        firstTab = tabs[0].id;
+
+        browser.test.log("Open options page. Expect fresh load.");
+        return Promise.all([
+          browser.runtime.openOptionsPage(),
+          awaitOptions(),
+        ]);
+      }).then(([, tab]) => {
+        browser.test.assertEq("about:addons", tab.url, "Tab contains AddonManager");
+        browser.test.assertTrue(tab.active, "Tab is active");
+        browser.test.assertTrue(tab.id != firstTab, "Tab is a new tab");
+
+        optionsTab = tab.id;
+
+        browser.test.log("Switch tabs.");
+        return browser.tabs.update(firstTab, {active: true});
+      }).then(() => {
+        browser.test.log("Open options page again. Expect tab re-selected, no new load.");
+
+        return browser.runtime.openOptionsPage();
+      }).then(() => {
+        return browser.tabs.query({currentWindow: true, active: true});
+      }).then(([tab]) => {
+        browser.test.assertEq(optionsTab, tab.id, "Tab is the same as the previous options tab");
+        browser.test.assertEq("about:addons", tab.url, "Tab contains AddonManager");
+
+        browser.test.log("Ping options page.");
+        return new Promise(resolve => browser.tabs.sendMessage(optionsTab, "ping", resolve));
+      }).then(() => {
+        browser.test.log("Got pong.");
+
+        browser.test.log("Remove options tab.");
+        return browser.tabs.remove(optionsTab);
+      }).then(() => {
+        browser.test.log("Open options page again. Expect fresh load.");
+        return Promise.all([
+          browser.runtime.openOptionsPage(),
+          awaitOptions(),
+        ]);
+      }).then(([, tab]) => {
+        browser.test.assertEq("about:addons", tab.url, "Tab contains AddonManager");
+        browser.test.assertTrue(tab.active, "Tab is active");
+        browser.test.assertTrue(tab.id != optionsTab, "Tab is a new tab");
+
+        return browser.tabs.remove(tab.id);
+      }).then(() => {
+        browser.test.notifyPass("options-ui");
+      }).catch(error => {
+        browser.test.log(`Error: ${error} :: ${error.stack}`);
+        browser.test.notifyFail("options-ui");
+      });
+    },
+  });
+
+  yield extension.awaitFinish("options-ui");
+  yield extension.unload();
+
+  yield BrowserTestUtils.removeTab(tab);
+});
+
+add_task(function* test_tab_options() {
+  let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/");
+
+  let extension = yield loadExtension({
+    manifest: {
+      "options_ui": {
+        "page": "options.html",
+        "open_in_tab": true,
+      },
+    },
+
+    background: function() {
+      let _optionsPromise;
+      let awaitOptions = () => {
+        browser.test.assertFalse(_optionsPromise, "Should not be awaiting options already");
+
+        return new Promise(resolve => {
+          _optionsPromise = {resolve};
+        });
+      };
+
+      browser.runtime.onMessage.addListener((msg, sender) => {
+        if (msg == "options.html") {
+          if (_optionsPromise) {
+            _optionsPromise.resolve(sender.tab);
+            _optionsPromise = null;
+          } else {
+            browser.test.fail("Saw unexpected options page load");
+          }
+        }
+      });
+
+      let optionsURL = browser.extension.getURL("options.html");
+
+      let firstTab, optionsTab;
+      browser.tabs.query({currentWindow: true, active: true}).then(tabs => {
+        firstTab = tabs[0].id;
+
+        browser.test.log("Open options page. Expect fresh load.");
+        return Promise.all([
+          browser.runtime.openOptionsPage(),
+          awaitOptions(),
+        ]);
+      }).then(([, tab]) => {
+        browser.test.assertEq(optionsURL, tab.url, "Tab contains options.html");
+        browser.test.assertTrue(tab.active, "Tab is active");
+        browser.test.assertTrue(tab.id != firstTab, "Tab is a new tab");
+
+        optionsTab = tab.id;
+
+        browser.test.log("Switch tabs.");
+        return browser.tabs.update(firstTab, {active: true});
+      }).then(() => {
+        browser.test.log("Open options page again. Expect tab re-selected, no new load.");
+
+        return browser.runtime.openOptionsPage();
+      }).then(() => {
+        return browser.tabs.query({currentWindow: true, active: true});
+      }).then(([tab]) => {
+        browser.test.assertEq(optionsTab, tab.id, "Tab is the same as the previous options tab");
+        browser.test.assertEq(optionsURL, tab.url, "Tab contains options.html");
+
+        // Unfortunately, we can't currently do this, since onMessage doesn't
+        // currently support responses when there are multiple listeners.
+        //
+        // browser.test.log("Ping options page.");
+        // return new Promise(resolve => browser.runtime.sendMessage("ping", resolve));
+
+        browser.test.log("Remove options tab.");
+        return browser.tabs.remove(optionsTab);
+      }).then(() => {
+        browser.test.log("Open options page again. Expect fresh load.");
+        return Promise.all([
+          browser.runtime.openOptionsPage(),
+          awaitOptions(),
+        ]);
+      }).then(([, tab]) => {
+        browser.test.assertEq(optionsURL, tab.url, "Tab contains options.html");
+        browser.test.assertTrue(tab.active, "Tab is active");
+        browser.test.assertTrue(tab.id != optionsTab, "Tab is a new tab");
+
+        return browser.tabs.remove(tab.id);
+      }).then(() => {
+        browser.test.notifyPass("options-ui-tab");
+      }).catch(error => {
+        browser.test.log(`Error: ${error} :: ${error.stack}`);
+        browser.test.notifyFail("options-ui-tab");
+      });
+    },
+  });
+
+  yield extension.awaitFinish("options-ui-tab");
+  yield extension.unload();
+
+  yield BrowserTestUtils.removeTab(tab);
+});
--- a/toolkit/components/extensions/.eslintrc
+++ b/toolkit/components/extensions/.eslintrc
@@ -12,16 +12,17 @@
     "TextEncoder": false,
     // Specific to WebExtensions:
     "extensions": true,
     "global": true,
     "Extension": true,
     "ExtensionManagement": true,
     "ExtensionPage": true,
     "GlobalManager": true,
+    "openOptionsPage": true,
     "runSafe": true,
     "runSafeSync": true,
     "runSafeSyncWithoutClone": true,
     "NetUtil": true,
     "Services": true,
     "TabManager": true,
     "XPCOMUtils": true,
   },
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -985,28 +985,91 @@ this.Extension.generateXPI = function(id
   }
 
   zipW.close();
 
   return file;
 };
 
 /**
+ * A skeleton Extension-like object, used for testing, which installs an
+ * add-on via the add-on manager when startup() is called, and
+ * uninstalles it on shutdown().
+ */
+function MockExtension(id, file, rootURI) {
+  this.id = id;
+  this.file = file;
+  this.rootURI = rootURI;
+
+  this._extension = null;
+  this._extensionPromise = new Promise(resolve => {
+    let onstartup = (msg, extension) => {
+      if (extension.id == this.id) {
+        Management.off("startup", onstartup);
+
+        this._extension = extension;
+        resolve(extension);
+      }
+    };
+    Management.on("startup", onstartup);
+  });
+}
+
+MockExtension.prototype = {
+  testMessage(...args) {
+    return this._extension.testMessage(...args);
+  },
+
+  on(...args) {
+    this._extensionPromise.then(extension => {
+      extension.on(...args);
+    });
+  },
+
+  off(...args) {
+    this._extensionPromise.then(extension => {
+      extension.off(...args);
+    });
+  },
+
+  startup() {
+    return AddonManager.installTemporaryAddon(this.file).then(addon => {
+      this.addon = addon;
+      return this._extensionPromise;
+    });
+  },
+
+  shutdown() {
+    this.addon.uninstall(true);
+    return this.cleanupGeneratedFile();
+  },
+
+  cleanupGeneratedFile() {
+    flushJarCache(this.file);
+    return OS.File.remove(this.file.path);
+  },
+};
+
+/**
  * Generates a new extension using |Extension.generateXPI|, and initializes a
  * new |Extension| instance which will execute it.
  */
 this.Extension.generate = function(id, data) {
   let file = this.generateXPI(id, data);
 
   flushJarCache(file);
   Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", {path: file.path});
 
   let fileURI = Services.io.newFileURI(file);
   let jarURI = Services.io.newURI("jar:" + fileURI.spec + "!/", null, null);
 
+  if (data.useAddonManager) {
+    return new MockExtension(id, file, jarURI);
+  }
+
   return new Extension({
     id,
     resourceURI: jarURI,
     cleanupFile: file,
   });
 };
 
 Extension.prototype = extend(Object.create(ExtensionData.prototype), {
--- a/toolkit/components/extensions/MessageChannel.jsm
+++ b/toolkit/components/extensions/MessageChannel.jsm
@@ -434,17 +434,19 @@ this.MessageChannel = {
    *    The message managers on which to stop listening.
    * @param {string|number} messageName
    *    The name of the message to stop listening for.
    * @param {MessageReceiver} handler
    *    The handler to stop dispatching to.
    */
   removeListener(targets, messageName, handler) {
     for (let target of [].concat(targets)) {
-      this.messageManagers.get(target).removeHandler(messageName, handler);
+      if (this.messageManagers.has(target)) {
+        this.messageManagers.get(target).removeHandler(messageName, handler);
+      }
     }
   },
 
   /**
    * Sends a message via the given message manager. Returns a promise which
    * resolves or rejects with the return value of the message receiver.
    *
    * The promise also rejects if there is no matching listener, or the other
--- a/toolkit/components/extensions/ext-runtime.js
+++ b/toolkit/components/extensions/ext-runtime.js
@@ -63,16 +63,24 @@ extensions.registerSchemaAPI("runtime", 
       getURL: function(url) {
         return extension.baseURI.resolve(url);
       },
 
       getPlatformInfo: function() {
         return Promise.resolve(ExtensionUtils.PlatformInfo);
       },
 
+      openOptionsPage: function() {
+        if (!extension.manifest.options_ui) {
+          return Promise.reject({message: "No `options_ui` declared"});
+        }
+
+        return openOptionsPage(extension).then(() => {});
+      },
+
       setUninstallURL: function(url) {
         if (url.length == 0) {
           return Promise.resolve();
         }
 
         let uri;
         try {
           uri = NetUtil.newURI(url);
--- a/toolkit/components/extensions/schemas/runtime.json
+++ b/toolkit/components/extensions/schemas/runtime.json
@@ -129,17 +129,16 @@
                 "description": "The JavaScript 'window' object for the background page."
               }
             ]
           }
         ]
       },
       {
         "name": "openOptionsPage",
-        "unsupported": true,
         "type": "function",
         "description": "<p>Open your Extension's options page, if possible.</p><p>The precise behavior may depend on your manifest's <code>$(topic:optionsV2)[options_ui]</code> or <code>$(topic:options)[options_page]</code> key, or what the browser happens to support at the time.</p><p>If your Extension does not declare an options page, or the browser failed to create one for some other reason, the callback will set $(ref:lastError).</p>",
         "async": "callback",
         "parameters": [{
           "type": "function",
           "name": "callback",
           "parameters": [],
           "optional": true
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -3813,18 +3813,19 @@ this.XPIProvider = {
   /**
    * Temporarily installs add-on from a local XPI file or directory.
    * As this is intended for development, the signature is not checked and
    * the add-on does not persist on application restart.
    *
    * @param aFile
    *        An nsIFile for the unpacked add-on directory or XPI file.
    *
-   * @return a Promise that rejects if the add-on is not a valid restartless
-   *         add-on or if the same ID is already temporarily installed
+   * @return a Promise that resolves to an Addon object on success, or rejects
+   *         if the add-on is not a valid restartless add-on or if the
+   *         same ID is already temporarily installed
    */
   installTemporaryAddon: Task.async(function*(aFile) {
     let addon = yield loadManifestFromFile(aFile, TemporaryInstallLocation);
 
     if (!addon.bootstrap) {
       throw new Error("Only restartless (bootstrap) add-ons"
                     + " can be temporarily installed:", addon.id);
     }