summaryrefslogtreecommitdiff
path: root/modules/ContentSearch.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ContentSearch.jsm')
-rw-r--r--modules/ContentSearch.jsm566
1 files changed, 566 insertions, 0 deletions
diff --git a/modules/ContentSearch.jsm b/modules/ContentSearch.jsm
new file mode 100644
index 0000000..91b0b9a
--- /dev/null
+++ b/modules/ContentSearch.jsm
@@ -0,0 +1,566 @@
+/* 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/. */
+/* globals XPCOMUtils, Services, Task, Promise, SearchSuggestionController, FormHistory, PrivateBrowsingUtils */
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+ "ContentSearch",
+];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FormHistory",
+ "resource://gre/modules/FormHistory.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "SearchSuggestionController",
+ "resource://gre/modules/SearchSuggestionController.jsm");
+
+const INBOUND_MESSAGE = "ContentSearch";
+const OUTBOUND_MESSAGE = INBOUND_MESSAGE;
+const MAX_LOCAL_SUGGESTIONS = 3;
+const MAX_SUGGESTIONS = 6;
+
+/**
+ * ContentSearch receives messages named INBOUND_MESSAGE and sends messages
+ * named OUTBOUND_MESSAGE. The data of each message is expected to look like
+ * { type, data }. type is the message's type (or subtype if you consider the
+ * type of the message itself to be INBOUND_MESSAGE), and data is data that is
+ * specific to the type.
+ *
+ * Inbound messages have the following types:
+ *
+ * AddFormHistoryEntry
+ * Adds an entry to the search form history.
+ * data: the entry, a string
+ * GetSuggestions
+ * Retrieves an array of search suggestions given a search string.
+ * data: { engineName, searchString, [remoteTimeout] }
+ * GetState
+ * Retrieves the current search engine state.
+ * data: null
+ * GetStrings
+ * Retrieves localized search UI strings.
+ * data: null
+ * ManageEngines
+ * Opens the search engine management window.
+ * data: null
+ * RemoveFormHistoryEntry
+ * Removes an entry from the search form history.
+ * data: the entry, a string
+ * Search
+ * Performs a search.
+ * Any GetSuggestions messages in the queue from the same target will be
+ * cancelled.
+ * data: { engineName, searchString, healthReportKey, searchPurpose }
+ * SetCurrentEngine
+ * Sets the current engine.
+ * data: the name of the engine
+ * SpeculativeConnect
+ * Speculatively connects to an engine.
+ * data: the name of the engine
+ *
+ * Outbound messages have the following types:
+ *
+ * CurrentEngine
+ * Broadcast when the current engine changes.
+ * data: see _currentEngineObj
+ * CurrentState
+ * Broadcast when the current search state changes.
+ * data: see currentStateObj
+ * State
+ * Sent in reply to GetState.
+ * data: see currentStateObj
+ * Strings
+ * Sent in reply to GetStrings
+ * data: Object containing string names and values for the current locale.
+ * Suggestions
+ * Sent in reply to GetSuggestions.
+ * data: see _onMessageGetSuggestions
+ * SuggestionsCancelled
+ * Sent in reply to GetSuggestions when pending GetSuggestions events are
+ * cancelled.
+ * data: null
+ */
+
+this.ContentSearch = {
+
+ // Inbound events are queued and processed in FIFO order instead of handling
+ // them immediately, which would result in non-FIFO responses due to the
+ // asynchrononicity added by converting image data URIs to ArrayBuffers.
+ _eventQueue: [],
+ _currentEventPromise: null,
+
+ // This is used to handle search suggestions. It maps xul:browsers to objects
+ // { controller, previousFormHistoryResult }. See _onMessageGetSuggestions.
+ _suggestionMap: new WeakMap(),
+
+ // Resolved when we finish shutting down.
+ _destroyedPromise: null,
+
+ // The current controller and browser in _onMessageGetSuggestions. Allows
+ // fetch cancellation from _cancelSuggestions.
+ _currentSuggestion: null,
+
+ init: function () {
+ Cc["@mozilla.org/globalmessagemanager;1"].
+ getService(Ci.nsIMessageListenerManager).
+ addMessageListener(INBOUND_MESSAGE, this);
+ Services.obs.addObserver(this, "browser-search-engine-modified", false);
+ Services.obs.addObserver(this, "shutdown-leaks-before-check", false);
+ Services.prefs.addObserver("browser.search.hiddenOneOffs", this, false);
+ this._stringBundle = Services.strings.createBundle("chrome://global/locale/autocomplete.properties");
+ },
+
+ get searchSuggestionUIStrings() {
+ if (this._searchSuggestionUIStrings) {
+ return this._searchSuggestionUIStrings;
+ }
+ this._searchSuggestionUIStrings = {};
+ let searchBundle = Services.strings.createBundle("chrome://browser/locale/search.properties");
+ let stringNames = ["searchHeader", "searchPlaceholder", "searchForSomethingWith",
+ "searchWithHeader", "searchSettings"];
+
+ for (let name of stringNames) {
+ this._searchSuggestionUIStrings[name] = searchBundle.GetStringFromName(name);
+ }
+ return this._searchSuggestionUIStrings;
+ },
+
+ destroy: function () {
+ if (this._destroyedPromise) {
+ return this._destroyedPromise;
+ }
+
+ Cc["@mozilla.org/globalmessagemanager;1"].
+ getService(Ci.nsIMessageListenerManager).
+ removeMessageListener(INBOUND_MESSAGE, this);
+ Services.obs.removeObserver(this, "browser-search-engine-modified");
+ Services.obs.removeObserver(this, "shutdown-leaks-before-check");
+
+ this._eventQueue.length = 0;
+ this._destroyedPromise = Promise.resolve(this._currentEventPromise);
+ return this._destroyedPromise;
+ },
+
+ /**
+ * Focuses the search input in the page with the given message manager.
+ * @param messageManager
+ * The MessageManager object of the selected browser.
+ */
+ focusInput: function (messageManager) {
+ messageManager.sendAsyncMessage(OUTBOUND_MESSAGE, {
+ type: "FocusInput"
+ });
+ },
+
+ receiveMessage: function (msg) {
+ // Add a temporary event handler that exists only while the message is in
+ // the event queue. If the message's source docshell changes browsers in
+ // the meantime, then we need to update msg.target. event.detail will be
+ // the docshell's new parent <xul:browser> element.
+ msg.handleEvent = event => {
+ let browserData = this._suggestionMap.get(msg.target);
+ if (browserData) {
+ this._suggestionMap.delete(msg.target);
+ this._suggestionMap.set(event.detail, browserData);
+ }
+ msg.target.removeEventListener("SwapDocShells", msg, true);
+ msg.target = event.detail;
+ msg.target.addEventListener("SwapDocShells", msg, true);
+ };
+ msg.target.addEventListener("SwapDocShells", msg, true);
+
+ // Search requests cause cancellation of all Suggestion requests from the
+ // same browser.
+ if (msg.data.type === "Search") {
+ this._cancelSuggestions(msg);
+ }
+
+ this._eventQueue.push({
+ type: "Message",
+ data: msg,
+ });
+ this._processEventQueue();
+ },
+
+ observe: function (subj, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ case "browser-search-engine-modified":
+ this._eventQueue.push({
+ type: "Observe",
+ data: data,
+ });
+ this._processEventQueue();
+ break;
+ case "shutdown-leaks-before-check":
+ subj.wrappedJSObject.client.addBlocker(
+ "ContentSearch: Wait until the service is destroyed", () => this.destroy());
+ break;
+ }
+ },
+
+ removeFormHistoryEntry: function (msg, entry) {
+ let browserData = this._suggestionDataForBrowser(msg.target);
+ if (browserData && browserData.previousFormHistoryResult) {
+ let { previousFormHistoryResult } = browserData;
+ for (let i = 0; i < previousFormHistoryResult.matchCount; i++) {
+ if (previousFormHistoryResult.getValueAt(i) === entry) {
+ previousFormHistoryResult.removeValueAt(i, true);
+ break;
+ }
+ }
+ }
+ },
+
+ performSearch: function (msg, data) {
+ this._ensureDataHasProperties(data, [
+ "engineName",
+ "searchString",
+ "healthReportKey",
+ "searchPurpose",
+ ]);
+ let engine = Services.search.getEngineByName(data.engineName);
+ let submission = engine.getSubmission(data.searchString, "", data.searchPurpose);
+ let browser = msg.target;
+ let win = browser.ownerGlobal;
+ if (!win) {
+ // The browser may have been closed between the time its content sent the
+ // message and the time we handle it.
+ return;
+ }
+ let where = win.whereToOpenLink(data.originalEvent);
+
+ // There is a chance that by the time we receive the search message, the user
+ // has switched away from the tab that triggered the search. If, based on the
+ // event, we need to load the search in the same tab that triggered it (i.e.
+ // where === "current"), openUILinkIn will not work because that tab is no
+ // longer the current one. For this case we manually load the URI.
+ if (where === "current") {
+ browser.loadURIWithFlags(submission.uri.spec,
+ Ci.nsIWebNavigation.LOAD_FLAGS_NONE, null, null,
+ submission.postData);
+ } else {
+ let params = {
+ postData: submission.postData,
+ inBackground: Services.prefs.getBoolPref("browser.tabs.loadInBackground"),
+ };
+ win.openUILinkIn(submission.uri.spec, where, params);
+ }
+ win.BrowserSearch.recordSearchInTelemetry(engine, data.healthReportKey,
+ { selection: data.selection });
+ return;
+ },
+
+ getSuggestions: Task.async(function* (engineName, searchString, browser, remoteTimeout=null) {
+ let engine = Services.search.getEngineByName(engineName);
+ if (!engine) {
+ throw new Error("Unknown engine name: " + engineName);
+ }
+
+ let browserData = this._suggestionDataForBrowser(browser, true);
+ let { controller } = browserData;
+ let ok = SearchSuggestionController.engineOffersSuggestions(engine);
+ controller.maxLocalResults = ok ? MAX_LOCAL_SUGGESTIONS : MAX_SUGGESTIONS;
+ controller.maxRemoteResults = ok ? MAX_SUGGESTIONS : 0;
+ controller.remoteTimeout = remoteTimeout || undefined;
+ let priv = PrivateBrowsingUtils.isBrowserPrivate(browser);
+ // fetch() rejects its promise if there's a pending request, but since we
+ // process our event queue serially, there's never a pending request.
+ this._currentSuggestion = { controller: controller, target: browser };
+ let suggestions = yield controller.fetch(searchString, priv, engine);
+ this._currentSuggestion = null;
+
+ // suggestions will be null if the request was cancelled
+ let result = {};
+ if (!suggestions) {
+ return result;
+ }
+
+ // Keep the form history result so RemoveFormHistoryEntry can remove entries
+ // from it. Keeping only one result isn't foolproof because the client may
+ // try to remove an entry from one set of suggestions after it has requested
+ // more but before it's received them. In that case, the entry may not
+ // appear in the new suggestions. But that should happen rarely.
+ browserData.previousFormHistoryResult = suggestions.formHistoryResult;
+ result = {
+ engineName,
+ term: suggestions.term,
+ local: suggestions.local,
+ remote: suggestions.remote,
+ };
+ return result;
+ }),
+
+ addFormHistoryEntry: Task.async(function* (browser, entry="") {
+ let isPrivate = false;
+ try {
+ // isBrowserPrivate assumes that the passed-in browser has all the normal
+ // properties, which won't be true if the browser has been destroyed.
+ // That may be the case here due to the asynchronous nature of messaging.
+ isPrivate = PrivateBrowsingUtils.isBrowserPrivate(browser.target);
+ } catch (err) {
+ return false;
+ }
+ if (isPrivate || entry === "") {
+ return false;
+ }
+ let browserData = this._suggestionDataForBrowser(browser.target, true);
+ FormHistory.update({
+ op: "bump",
+ fieldname: browserData.controller.formHistoryParam,
+ value: entry,
+ }, {
+ handleCompletion: () => {},
+ handleError: err => {
+ Cu.reportError("Error adding form history entry: " + err);
+ },
+ });
+ return true;
+ }),
+
+ currentStateObj: Task.async(function* (uriFlag=false) {
+ let state = {
+ engines: [],
+ currentEngine: yield this._currentEngineObj(),
+ };
+ if (uriFlag) {
+ state.currentEngine.iconBuffer = Services.search.currentEngine.getIconURLBySize(16, 16);
+ }
+ let pref = Services.prefs.getCharPref("browser.search.hiddenOneOffs");
+ let hiddenList = pref ? pref.split(",") : [];
+ for (let engine of Services.search.getVisibleEngines()) {
+ let uri = engine.getIconURLBySize(16, 16);
+ let iconBuffer = uri;
+ if (!uriFlag) {
+ iconBuffer = yield this._arrayBufferFromDataURI(uri);
+ }
+ state.engines.push({
+ name: engine.name,
+ iconBuffer,
+ hidden: hiddenList.indexOf(engine.name) !== -1,
+ });
+ }
+ return state;
+ }),
+
+ _processEventQueue: function () {
+ if (this._currentEventPromise || !this._eventQueue.length) {
+ return;
+ }
+
+ let event = this._eventQueue.shift();
+
+ this._currentEventPromise = Task.spawn(function* () {
+ try {
+ yield this["_on" + event.type](event.data);
+ } catch (err) {
+ Cu.reportError(err);
+ } finally {
+ this._currentEventPromise = null;
+ this._processEventQueue();
+ }
+ }.bind(this));
+ },
+
+ _cancelSuggestions: function (msg) {
+ let cancelled = false;
+ // cancel active suggestion request
+ if (this._currentSuggestion && this._currentSuggestion.target === msg.target) {
+ this._currentSuggestion.controller.stop();
+ cancelled = true;
+ }
+ // cancel queued suggestion requests
+ for (let i = 0; i < this._eventQueue.length; i++) {
+ let m = this._eventQueue[i].data;
+ if (msg.target === m.target && m.data.type === "GetSuggestions") {
+ this._eventQueue.splice(i, 1);
+ cancelled = true;
+ i--;
+ }
+ }
+ if (cancelled) {
+ this._reply(msg, "SuggestionsCancelled");
+ }
+ },
+
+ _onMessage: Task.async(function* (msg) {
+ let methodName = "_onMessage" + msg.data.type;
+ if (methodName in this) {
+ yield this._initService();
+ yield this[methodName](msg, msg.data.data);
+ if (!Cu.isDeadWrapper(msg.target)) {
+ msg.target.removeEventListener("SwapDocShells", msg, true);
+ }
+ }
+ }),
+
+ _onMessageGetState: function (msg, data) {
+ return this.currentStateObj().then(state => {
+ this._reply(msg, "State", state);
+ });
+ },
+
+ _onMessageGetStrings: function (msg, data) {
+ this._reply(msg, "Strings", this.searchSuggestionUIStrings);
+ },
+
+ _onMessageSearch: function (msg, data) {
+ this.performSearch(msg, data);
+ },
+
+ _onMessageSetCurrentEngine: function (msg, data) {
+ Services.search.currentEngine = Services.search.getEngineByName(data);
+ },
+
+ _onMessageManageEngines: function (msg, data) {
+ let browserWin = msg.target.ownerGlobal;
+ browserWin.openPreferences("paneSearch");
+ },
+
+ _onMessageGetSuggestions: Task.async(function* (msg, data) {
+ this._ensureDataHasProperties(data, [
+ "engineName",
+ "searchString",
+ ]);
+ let {engineName, searchString} = data;
+ let suggestions = yield this.getSuggestions(engineName, searchString, msg.target);
+
+ this._reply(msg, "Suggestions", {
+ engineName: data.engineName,
+ searchString: suggestions.term,
+ formHistory: suggestions.local,
+ remote: suggestions.remote,
+ });
+ }),
+
+ _onMessageAddFormHistoryEntry: Task.async(function* (msg, entry) {
+ yield this.addFormHistoryEntry(msg, entry);
+ }),
+
+ _onMessageRemoveFormHistoryEntry: function (msg, entry) {
+ this.removeFormHistoryEntry(msg, entry);
+ },
+
+ _onMessageSpeculativeConnect: function (msg, engineName) {
+ let engine = Services.search.getEngineByName(engineName);
+ if (!engine) {
+ throw new Error("Unknown engine name: " + engineName);
+ }
+ if (msg.target.contentWindow) {
+ engine.speculativeConnect({
+ window: msg.target.contentWindow,
+ });
+ }
+ },
+
+ _onObserve: Task.async(function* (data) {
+ if (data === "engine-current") {
+ let engine = yield this._currentEngineObj();
+ this._broadcast("CurrentEngine", engine);
+ }
+ else if (data !== "engine-default") {
+ // engine-default is always sent with engine-current and isn't otherwise
+ // relevant to content searches.
+ let state = yield this.currentStateObj();
+ this._broadcast("CurrentState", state);
+ }
+ }),
+
+ _suggestionDataForBrowser: function (browser, create=false) {
+ let data = this._suggestionMap.get(browser);
+ if (!data && create) {
+ // Since one SearchSuggestionController instance is meant to be used per
+ // autocomplete widget, this means that we assume each xul:browser has at
+ // most one such widget.
+ data = {
+ controller: new SearchSuggestionController(),
+ };
+ this._suggestionMap.set(browser, data);
+ }
+ return data;
+ },
+
+ _reply: function (msg, type, data) {
+ // We reply asyncly to messages, and by the time we reply the browser we're
+ // responding to may have been destroyed. messageManager is null then.
+ if (!Cu.isDeadWrapper(msg.target) && msg.target.messageManager) {
+ msg.target.messageManager.sendAsyncMessage(...this._msgArgs(type, data));
+ }
+ },
+
+ _broadcast: function (type, data) {
+ Cc["@mozilla.org/globalmessagemanager;1"].
+ getService(Ci.nsIMessageListenerManager).
+ broadcastAsyncMessage(...this._msgArgs(type, data));
+ },
+
+ _msgArgs: function (type, data) {
+ return [OUTBOUND_MESSAGE, {
+ type: type,
+ data: data,
+ }];
+ },
+
+ _currentEngineObj: Task.async(function* () {
+ let engine = Services.search.currentEngine;
+ let favicon = engine.getIconURLBySize(16, 16);
+ let placeholder = this._stringBundle.formatStringFromName(
+ "searchWithEngine", [engine.name], 1);
+ let obj = {
+ name: engine.name,
+ placeholder: placeholder,
+ iconBuffer: yield this._arrayBufferFromDataURI(favicon),
+ };
+ return obj;
+ }),
+
+ _arrayBufferFromDataURI: function (uri) {
+ if (!uri) {
+ return Promise.resolve(null);
+ }
+ let deferred = Promise.defer();
+ let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
+ createInstance(Ci.nsIXMLHttpRequest);
+ xhr.open("GET", uri, true);
+ xhr.responseType = "arraybuffer";
+ xhr.onload = () => {
+ deferred.resolve(xhr.response);
+ };
+ xhr.onerror = xhr.onabort = xhr.ontimeout = () => {
+ deferred.resolve(null);
+ };
+ try {
+ // This throws if the URI is erroneously encoded.
+ xhr.send();
+ }
+ catch (err) {
+ return Promise.resolve(null);
+ }
+ return deferred.promise;
+ },
+
+ _ensureDataHasProperties: function (data, requiredProperties) {
+ for (let prop of requiredProperties) {
+ if (!(prop in data)) {
+ throw new Error("Message data missing required property: " + prop);
+ }
+ }
+ },
+
+ _initService: function () {
+ if (!this._initServicePromise) {
+ let deferred = Promise.defer();
+ this._initServicePromise = deferred.promise;
+ Services.search.init(() => deferred.resolve());
+ }
+ return this._initServicePromise;
+ },
+};