diff --git a/browser/components/extensions/ext-browser.json b/browser/components/extensions/ext-browser.json index 92d28cf251cc..771bd354c37e 100644 --- a/browser/components/extensions/ext-browser.json +++ b/browser/components/extensions/ext-browser.json @@ -143,6 +143,14 @@ ["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", diff --git a/browser/components/extensions/jar.mn b/browser/components/extensions/jar.mn index 9e8c6e8e5be8..83d826e70703 100644 --- a/browser/components/extensions/jar.mn +++ b/browser/components/extensions/jar.mn @@ -30,6 +30,7 @@ browser.jar: 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) diff --git a/browser/components/extensions/parent/.eslintrc.js b/browser/components/extensions/parent/.eslintrc.js index 0bacb31c9b7d..4704e0615010 100644 --- a/browser/components/extensions/parent/.eslintrc.js +++ b/browser/components/extensions/parent/.eslintrc.js @@ -21,6 +21,7 @@ module.exports = { "openOptionsPage": true, "pageActionFor": true, "replaceUrlInTab": true, + "searchInitialized": true, "sidebarActionFor": true, "tabGetSender": true, "tabTracker": true, diff --git a/browser/components/extensions/parent/ext-browser.js b/browser/components/extensions/parent/ext-browser.js index 55ca09c6e54e..5051e4953dda 100644 --- a/browser/components/extensions/parent/ext-browser.js +++ b/browser/components/extensions/parent/ext-browser.js @@ -223,6 +223,16 @@ global.TabContext = class extends EventEmitter { } }; +// 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) { diff --git a/browser/components/extensions/parent/ext-chrome-settings-overrides.js b/browser/components/extensions/parent/ext-chrome-settings-overrides.js index b0fe84d3a180..7cd2fdfadd67 100644 --- a/browser/components/extensions/parent/ext-chrome-settings-overrides.js +++ b/browser/components/extensions/parent/ext-chrome-settings-overrides.js @@ -20,27 +20,6 @@ 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, @@ -135,7 +114,7 @@ this.chrome_settings_overrides = class extends ExtensionAPI { 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); @@ -211,7 +190,7 @@ this.chrome_settings_overrides = class extends ExtensionAPI { }); } if (manifest.chrome_settings_overrides.search_provider) { - await searchInitialized(); + await searchInitialized; extension.callOnClose({ close: () => { if (extension.shutdownReason == "ADDON_DISABLE") { diff --git a/browser/components/extensions/parent/ext-search.js b/browser/components/extensions/parent/ext-search.js new file mode 100644 index 000000000000..d94c64f4763a --- /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); + } + }, + }, + }; + } +}; diff --git a/browser/components/extensions/schemas/jar.mn b/browser/components/extensions/schemas/jar.mn index 4a88b881589e..743766bc1018 100644 --- a/browser/components/extensions/schemas/jar.mn +++ b/browser/components/extensions/schemas/jar.mn @@ -20,6 +20,7 @@ browser.jar: 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 diff --git a/browser/components/extensions/schemas/search.json b/browser/components/extensions/schemas/search.json new file mode 100644 index 000000000000..69d22cf146d5 --- /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 + } + ] + } + ] + } +] diff --git a/browser/components/extensions/test/browser/browser-common.ini b/browser/components/extensions/test/browser/browser-common.ini index 457facf27694..b44fba1f4e56 100644 --- a/browser/components/extensions/test/browser/browser-common.ini +++ b/browser/components/extensions/test/browser/browser-common.ini @@ -134,6 +134,7 @@ skip-if = !e10s || !crashreporter # the tab's process is killed during the test. [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] diff --git a/browser/components/extensions/test/browser/browser_ext_search.js b/browser/components/extensions/test/browser/browser_ext_search.js new file mode 100644 index 000000000000..6b050cc612b4 --- /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(); +}); diff --git a/browser/components/extensions/test/mochitest/test_ext_all_apis.html b/browser/components/extensions/test/mochitest/test_ext_all_apis.html index 0cf5cab37489..7a65238e76ba 100644 --- a/browser/components/extensions/test/mochitest/test_ext_all_apis.html +++ b/browser/components/extensions/test/mochitest/test_ext_all_apis.html @@ -16,6 +16,7 @@ let expectedContentApisTargetSpecific = [ ]; let expectedBackgroundApisTargetSpecific = [ + "search.get", "tabs.MutedInfoReason", "tabs.TAB_ID_NONE", "tabs.TabStatus",