Bug 1352598 - Add WebExtension API for access to search. r?aswan draft
authorMichael Kaply <mozilla@kaply.com>
Mon, 18 Jun 2018 10:39:12 -0500
changeset 814340 ef6da636513e7f302740a17263de5e10a5ffecd0
parent 813581 a0e47ebc4c06e652b919dabee711fdbd6bfd31b5
push id115165
push usermozilla@kaply.com
push dateWed, 04 Jul 2018 23:38:51 +0000
reviewersaswan
bugs1352598
milestone63.0a1
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,96 @@
+/* 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");
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["fetch", "btoa"]);
+
+var {
+  ExtensionError,
+} = ExtensionUtils;
+
+async function getDataURI(resourceURI) {
+  let response = await fetch(resourceURI);
+  let buffer = await response.arrayBuffer();
+  let contentType = response.headers.get("content-type");
+  let bytes = new Uint8Array(buffer);
+  let str = String.fromCharCode.apply(null, bytes);
+  return `data:${contentType};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.schemeIs("resource") ||
+                  engine.iconURI.schemeIs("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,96 @@
+/* -*- 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() {
+    browser.browserAction.onClicked.addListener(tab => {
+      browser.search.search("Search Test", "test", tab.id); // Can't use SEARCH_TERM here
+    });
+    browser.tabs.onUpdated.addListener(async function(tabId, info, changedTab) {
+      if (changedTab.url == "about:blank") {
+        // Ignore events related to the initial tab open.
+        return;
+      }
+      if (info.status === "complete") {
+        await browser.tabs.remove(tabId);
+        browser.test.sendMessage("searchLoaded", changedTab.url);
+      }
+    });
+    await browser.tabs.create({url: "about:blank"});
+    let engines = await browser.search.get();
+    browser.test.sendMessage("engines", engines);
+  }
+
+  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",