diff options
Diffstat (limited to 'browser/modules/ContentSearch.jsm')
-rw-r--r-- | browser/modules/ContentSearch.jsm | 566 |
1 files changed, 566 insertions, 0 deletions
diff --git a/browser/modules/ContentSearch.jsm b/browser/modules/ContentSearch.jsm new file mode 100644 index 000000000..91b0b9ac8 --- /dev/null +++ b/browser/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; + }, +}; |