Bug 1352598 - Add WebExtension API for access to search. r=aswan
☠☠ backed out by 6e281abe4d39 ☠ ☠
authorMichael Kaply <mozilla@kaply.com>
Mon, 18 Jun 2018 10:39:12 -0500
changeset 424948 f5bfc9971285d5edcf12e4610d521eb4bedf7a59
parent 424947 058b07756df6e4e2530aa1847936dae16dddd58c
child 424949 984abfb799a1b8a484a3ed4a58ee4aa919b0c841
push id104946
push userrgurzau@mozilla.com
push dateWed, 04 Jul 2018 10:03:16 +0000
treeherdermozilla-inbound@796893f4d2f5 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan
bugs1352598
milestone63.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1352598 - Add WebExtension API for access to search. r=aswan MozReview-Commit-ID: 4pV2DGMcV7G
browser/components/extensions/ext-browser.json
browser/components/extensions/jar.mn
browser/components/extensions/parent/.eslintrc.js
browser/components/extensions/parent/ext-browser.js
browser/components/extensions/parent/ext-chrome-settings-overrides.js
browser/components/extensions/parent/ext-search.js
browser/components/extensions/schemas/jar.mn
browser/components/extensions/schemas/search.json
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_search.js
browser/components/extensions/test/mochitest/test_ext_all_apis.html
--- a/browser/components/extensions/ext-browser.json
+++ b/browser/components/extensions/ext-browser.json
@@ -138,16 +138,24 @@
   "geckoProfiler": {
     "url": "chrome://browser/content/parent/ext-geckoProfiler.js",
     "schema": "chrome://browser/content/schemas/geckoProfiler.json",
     "scopes": ["addon_parent"],
     "paths": [
       ["geckoProfiler"]
     ]
   },
+  "search": {
+    "url": "chrome://browser/content/parent/ext-search.js",
+    "schema": "chrome://browser/content/schemas/search.json",
+    "scopes": ["addon_parent"],
+    "paths": [
+      ["search"]
+    ]
+  },
   "sessions": {
     "url": "chrome://browser/content/parent/ext-sessions.js",
     "schema": "chrome://browser/content/schemas/sessions.json",
     "scopes": ["addon_parent"],
     "paths": [
       ["sessions"]
     ]
   },
--- a/browser/components/extensions/jar.mn
+++ b/browser/components/extensions/jar.mn
@@ -25,16 +25,17 @@ browser.jar:
     content/browser/parent/ext-devtools-panels.js (parent/ext-devtools-panels.js)
     content/browser/parent/ext-find.js (parent/ext-find.js)
     content/browser/parent/ext-geckoProfiler.js (parent/ext-geckoProfiler.js)
     content/browser/parent/ext-history.js (parent/ext-history.js)
     content/browser/parent/ext-menus.js (parent/ext-menus.js)
     content/browser/parent/ext-omnibox.js (parent/ext-omnibox.js)
     content/browser/parent/ext-pageAction.js (parent/ext-pageAction.js)
     content/browser/parent/ext-pkcs11.js (parent/ext-pkcs11.js)
+    content/browser/parent/ext-search.js (parent/ext-search.js)
     content/browser/parent/ext-sessions.js (parent/ext-sessions.js)
     content/browser/parent/ext-sidebarAction.js (parent/ext-sidebarAction.js)
     content/browser/parent/ext-tabs.js (parent/ext-tabs.js)
     content/browser/parent/ext-url-overrides.js (parent/ext-url-overrides.js)
     content/browser/parent/ext-windows.js (parent/ext-windows.js)
     content/browser/child/ext-browser.js (child/ext-browser.js)
     content/browser/child/ext-devtools-inspectedWindow.js (child/ext-devtools-inspectedWindow.js)
     content/browser/child/ext-devtools-network.js (child/ext-devtools-network.js)
--- a/browser/components/extensions/parent/.eslintrc.js
+++ b/browser/components/extensions/parent/.eslintrc.js
@@ -16,15 +16,16 @@ module.exports = {
     "getToolboxEvalOptions": true,
     "isContainerCookieStoreId": true,
     "isPrivateCookieStoreId": true,
     "isValidCookieStoreId": true,
     "makeWidgetId": true,
     "openOptionsPage": true,
     "pageActionFor": true,
     "replaceUrlInTab": true,
+    "searchInitialized": true,
     "sidebarActionFor": true,
     "tabGetSender": true,
     "tabTracker": true,
     "waitForTabLoaded": true,
     "windowTracker": true,
   },
 };
--- a/browser/components/extensions/parent/ext-browser.js
+++ b/browser/components/extensions/parent/ext-browser.js
@@ -218,16 +218,26 @@ global.TabContext = class extends EventE
    */
   shutdown() {
     windowTracker.removeListener("progress", this);
     windowTracker.removeListener("TabSelect", this);
     tabTracker.off("tab-adopted", this.tabAdopted);
   }
 };
 
+// This promise is used to wait for the search service to be initialized.
+// None of the code in the WebExtension modules requests that initialization.
+// It is assumed that it is started at some point. If tests start to fail
+// because this promise never resolves, that's likely the cause.
+XPCOMUtils.defineLazyGetter(global, "searchInitialized", () => {
+  if (Services.search.isInitialized) {
+    return Promise.resolve();
+  }
+  return ExtensionUtils.promiseObserved("browser-search-service", (_, data) => data == "init-complete");
+});
 
 class WindowTracker extends WindowTrackerBase {
   addProgressListener(window, listener) {
     window.gBrowser.addTabsProgressListener(listener);
   }
 
   removeProgressListener(window, listener) {
     window.gBrowser.removeTabsProgressListener(listener);
--- a/browser/components/extensions/parent/ext-chrome-settings-overrides.js
+++ b/browser/components/extensions/parent/ext-chrome-settings-overrides.js
@@ -15,37 +15,16 @@ const DEFAULT_SEARCH_STORE_TYPE = "defau
 const DEFAULT_SEARCH_SETTING_NAME = "defaultSearch";
 const ENGINE_ADDED_SETTING_NAME = "engineAdded";
 
 const HOMEPAGE_PREF = "browser.startup.homepage";
 const HOMEPAGE_CONFIRMED_TYPE = "homepageNotification";
 const HOMEPAGE_SETTING_TYPE = "prefs";
 const HOMEPAGE_SETTING_NAME = "homepage_override";
 
-// This promise is used to wait for the search service to be initialized.
-// None of the code in this module requests that initialization. It is assumed
-// that it is started at some point. If tests start to fail because this
-// promise never resolves, that's likely the cause.
-const searchInitialized = () => {
-  if (Services.search.isInitialized) {
-    return;
-  }
-  return new Promise(resolve => {
-    const SEARCH_SERVICE_TOPIC = "browser-search-service";
-    Services.obs.addObserver(function observer(subject, topic, data) {
-      if (data != "init-complete") {
-        return;
-      }
-
-      Services.obs.removeObserver(observer, SEARCH_SERVICE_TOPIC);
-      resolve();
-    }, SEARCH_SERVICE_TOPIC);
-  });
-};
-
 XPCOMUtils.defineLazyGetter(this, "homepagePopup", () => {
   return new ExtensionControlledPopup({
     confirmedType: HOMEPAGE_CONFIRMED_TYPE,
     observerTopic: "browser-open-homepage-start",
     popupnotificationId: "extension-homepage-notification",
     settingType: HOMEPAGE_SETTING_TYPE,
     settingKey: HOMEPAGE_SETTING_NAME,
     descriptionId: "extension-homepage-notification-description",
@@ -130,17 +109,17 @@ this.chrome_settings_overrides = class e
 
   static async removeEngine(id) {
     await ExtensionSettingsStore.initialize();
     let item = await ExtensionSettingsStore.getSetting(
       DEFAULT_SEARCH_STORE_TYPE, ENGINE_ADDED_SETTING_NAME, id);
     if (item) {
       ExtensionSettingsStore.removeSetting(
         id, DEFAULT_SEARCH_STORE_TYPE, ENGINE_ADDED_SETTING_NAME);
-      await searchInitialized();
+      await searchInitialized;
       let engine = Services.search.getEngineByName(item.value);
       try {
         Services.search.removeEngine(engine);
       } catch (e) {
         Cu.reportError(e);
       }
     }
   }
@@ -206,17 +185,17 @@ this.chrome_settings_overrides = class e
         close: () => {
           if (extension.shutdownReason == "ADDON_DISABLE") {
             homepagePopup.clearConfirmation(extension.id);
           }
         },
       });
     }
     if (manifest.chrome_settings_overrides.search_provider) {
-      await searchInitialized();
+      await searchInitialized;
       extension.callOnClose({
         close: () => {
           if (extension.shutdownReason == "ADDON_DISABLE") {
             chrome_settings_overrides.processDefaultSearchSetting("disable", extension.id);
             chrome_settings_overrides.removeEngine(extension.id);
           }
         },
       });
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/parent/ext-search.js
@@ -0,0 +1,97 @@
+/* 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/. */
+
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(this, "Services",
+                               "resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyPreferenceGetter(this, "searchLoadInBackground",
+                                      "browser.search.context.loadInBackground");
+
+Cu.importGlobalProperties(["fetch", "btoa"]);
+
+var {
+  ExtensionError,
+} = ExtensionUtils;
+
+async function getDataURI(resourceURI) {
+  let response = await fetch(resourceURI);
+  let buffer = await response.arrayBuffer();
+  // Remove charset from content type
+  let contentType = response.headers.get("content-type").split(",");
+  let bytes = new Uint8Array(buffer);
+  let str = String.fromCharCode.apply(null, bytes);
+  return `data:${contentType[0]};base64,${btoa(str)}`;
+}
+
+this.search = class extends ExtensionAPI {
+  getAPI(context) {
+    return {
+      search: {
+        async get() {
+          await searchInitialized;
+          let engines = Services.search.getEngines();
+          let visibleEngines = engines.filter(engine => !engine.hidden);
+          return Promise.all(visibleEngines.map(async engine => {
+            let favicon_url = null;
+            if (engine.iconURI) {
+              if (engine.iconURI.spec.startsWith("resource:") ||
+                  engine.iconURI.spec.startsWith("chrome:")) {
+                // Convert internal URLs to data URLs
+                favicon_url = await getDataURI(engine.iconURI.spec);
+              } else {
+                favicon_url = engine.iconURI.spec;
+              }
+            }
+
+            return {
+              name: engine.name,
+              is_default: engine === Services.search.currentEngine,
+              alias: engine.alias,
+              favicon_url,
+            };
+          }));
+        },
+
+        async search(name, searchTerms, tabId) {
+          await searchInitialized;
+          let engine = Services.search.getEngineByName(name);
+          if (!engine) {
+            throw new ExtensionError(`${name} was not found`);
+          }
+          let submission = engine.getSubmission(searchTerms, null, "webextension");
+          let options = {
+            postData: submission.postData,
+            triggeringPrincipal: context.principal,
+          };
+          if (tabId === null) {
+            let browser = context.pendingEventBrowser || context.xulBrowser;
+            let {gBrowser} = browser.ownerGlobal;
+            if (!gBrowser || !gBrowser.addTab) {
+              // In some cases (about:addons, sidebar, maybe others), we need
+              // to go up one more level.
+              browser = browser.ownerDocument.docShell.chromeEventHandler;
+
+              ({gBrowser} = browser.ownerGlobal);
+            }
+            if (!gBrowser || !gBrowser.addTab) {
+              throw new ExtensionError("Unable to locate a browser.");
+            }
+            let nativeTab = gBrowser.addTab(submission.uri.spec, options);
+            if (!searchLoadInBackground) {
+              gBrowser.selectedTab = nativeTab;
+            }
+          } else {
+            let tab = tabTracker.getTab(tabId);
+            tab.linkedBrowser.loadURI(submission.uri.spec, options);
+          }
+        },
+      },
+    };
+  }
+};
--- a/browser/components/extensions/schemas/jar.mn
+++ b/browser/components/extensions/schemas/jar.mn
@@ -15,13 +15,14 @@ browser.jar:
     content/browser/schemas/find.json
     content/browser/schemas/geckoProfiler.json
     content/browser/schemas/history.json
     content/browser/schemas/menus.json
     content/browser/schemas/menus_internal.json
     content/browser/schemas/omnibox.json
     content/browser/schemas/page_action.json
     content/browser/schemas/pkcs11.json
+    content/browser/schemas/search.json
     content/browser/schemas/sessions.json
     content/browser/schemas/sidebar_action.json
     content/browser/schemas/tabs.json
     content/browser/schemas/url_overrides.json
     content/browser/schemas/windows.json
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/schemas/search.json
@@ -0,0 +1,64 @@
+/* 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/. */
+
+[
+  {
+    "namespace": "search",
+    "description": "Use browser.search to interact with search engines.",
+    "types": [
+      {
+        "id": "SearchEngine",
+        "type": "object",
+        "description": "An object encapsulating a search engine",
+        "properties": {
+          "name": {
+            "type": "string"
+          },
+          "is_default": {
+            "type": "boolean"
+          },
+          "alias": {
+            "type": "string",
+            "optional": true
+          },
+          "favicon_url": {
+            "type": "string",
+            "optional": true,
+            "format": "url"
+          }
+        }
+      }
+    ],
+    "functions": [
+      {
+        "name": "get",
+        "type": "function",
+        "description": "Gets a list of search engines.",
+        "async": true,
+        "parameters": []
+      },
+      {
+        "name": "search",
+        "type": "function",
+        "requireUserInput": true,
+        "description": "Perform a search.",
+        "parameters": [
+          {
+            "name": "engineName",
+            "type": "string"
+          },
+          {
+            "name": "searchTerms",
+            "type": "string"
+          },
+          {
+            "type": "integer",
+            "name": "tabId",
+            "optional": true
+          }
+        ]
+      }
+    ]
+  }
+]
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -129,16 +129,17 @@ skip-if = (verify && debug && (os == 'ma
 disabled = bug 1438663
 [browser_ext_popup_sendMessage.js]
 [browser_ext_popup_shutdown.js]
 [browser_ext_port_disconnect_on_crash.js]
 skip-if = !e10s || !crashreporter # the tab's process is killed during the test. Without e10s the parent process would die too.
 [browser_ext_port_disconnect_on_window_close.js]
 [browser_ext_runtime_openOptionsPage.js]
 [browser_ext_runtime_openOptionsPage_uninstall.js]
+[browser_ext_search.js]
 [browser_ext_runtime_setUninstallURL.js]
 [browser_ext_sessions_forgetClosedTab.js]
 [browser_ext_sessions_forgetClosedWindow.js]
 [browser_ext_sessions_getRecentlyClosed.js]
 [browser_ext_sessions_getRecentlyClosed_private.js]
 [browser_ext_sessions_getRecentlyClosed_tabs.js]
 [browser_ext_sessions_restore.js]
 [browser_ext_sessions_restoreTab.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_search.js
@@ -0,0 +1,93 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_search() {
+  const TEST_ID = "test_search@tests.mozilla.com";
+  const SEARCH_TERM = "test";
+  const SEARCH_URL = "https://localhost/?q={searchTerms}";
+
+  async function background() {
+    await browser.tabs.create({url: "about:blank"});
+    let engines = await browser.search.get();
+    browser.test.sendMessage("engines", engines);
+    browser.browserAction.onClicked.addListener(tab => {
+      browser.tabs.onUpdated.addListener(async function(tabId, info, changedTab) {
+        if (tabId == tab.id && info.status === "complete" &&
+            changedTab.url != "about:blank") {
+          await browser.tabs.remove(tabId);
+          browser.test.sendMessage("searchLoaded", changedTab.url);
+        }
+      });
+      browser.search.search("Search Test", "test", tab.id); // Can't use SEARCH_TERM here
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: ["tabs"],
+      name: TEST_ID,
+      "browser_action": {},
+      "chrome_settings_overrides": {
+        "search_provider": {
+          "name": "Search Test",
+          "search_url": SEARCH_URL,
+        },
+      },
+    },
+    background,
+    useAddonManager: "temporary",
+  });
+  await extension.startup();
+
+  let addonEngines = await extension.awaitMessage("engines");
+  let engines = Services.search.getEngines().filter(engine => !engine.hidden);
+  is(addonEngines.length, engines.length, "Engine lengths are the same.");
+  let defaultEngine = addonEngines.filter(engine => engine.is_default === true);
+  is(defaultEngine.length, 1, "One default engine");
+  is(defaultEngine[0].name, Services.search.currentEngine.name, "Default engine is correct");
+  await clickBrowserAction(extension);
+  let url = await extension.awaitMessage("searchLoaded");
+  is(url, SEARCH_URL.replace("{searchTerms}", SEARCH_TERM), "Loaded page matches search");
+  await extension.unload();
+});
+
+add_task(async function test_search_notab() {
+  const TEST_ID = "test_search@tests.mozilla.com";
+  const SEARCH_TERM = "test";
+  const SEARCH_URL = "https://localhost/?q={searchTerms}";
+
+  async function background() {
+    browser.browserAction.onClicked.addListener(_ => {
+      browser.tabs.onUpdated.addListener(async (tabId, info, changedTab) => {
+        if (info.status === "complete") {
+          await browser.tabs.remove(tabId);
+          browser.test.sendMessage("searchLoaded", changedTab.url);
+        }
+      });
+      browser.search.search("Search Test", "test"); // Can't use SEARCH_TERM here
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: ["tabs"],
+      name: TEST_ID,
+      "browser_action": {},
+      "chrome_settings_overrides": {
+        "search_provider": {
+          "name": "Search Test",
+          "search_url": SEARCH_URL,
+        },
+      },
+    },
+    background,
+    useAddonManager: "temporary",
+  });
+  await extension.startup();
+
+  await clickBrowserAction(extension);
+  let url = await extension.awaitMessage("searchLoaded");
+  is(url, SEARCH_URL.replace("{searchTerms}", SEARCH_TERM), "Loaded page matches search");
+  await extension.unload();
+});
--- a/browser/components/extensions/test/mochitest/test_ext_all_apis.html
+++ b/browser/components/extensions/test/mochitest/test_ext_all_apis.html
@@ -11,16 +11,17 @@
 <body>
 <script>
 "use strict";
 /* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */
 let expectedContentApisTargetSpecific = [
 ];
 
 let expectedBackgroundApisTargetSpecific = [
+  "search.get",
   "tabs.MutedInfoReason",
   "tabs.TAB_ID_NONE",
   "tabs.TabStatus",
   "tabs.UpdatePropertyName",
   "tabs.WindowType",
   "tabs.ZoomSettingsMode",
   "tabs.ZoomSettingsScope",
   "tabs.connect",