Bumping manifests a=b2g-bump
[gecko.git] / browser / modules / ContentSearch.jsm
blob8448f2ace416da8b45a2a23179550252cc1fd79c
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
7 this.EXPORTED_SYMBOLS = [
8   "ContentSearch",
9 ];
11 const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
13 Cu.import("resource://gre/modules/Services.jsm");
14 Cu.import("resource://gre/modules/Promise.jsm");
15 Cu.import("resource://gre/modules/Task.jsm");
16 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
18 XPCOMUtils.defineLazyModuleGetter(this, "FormHistory",
19   "resource://gre/modules/FormHistory.jsm");
20 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
21   "resource://gre/modules/PrivateBrowsingUtils.jsm");
22 XPCOMUtils.defineLazyModuleGetter(this, "SearchSuggestionController",
23   "resource://gre/modules/SearchSuggestionController.jsm");
25 const INBOUND_MESSAGE = "ContentSearch";
26 const OUTBOUND_MESSAGE = INBOUND_MESSAGE;
28 /**
29  * ContentSearch receives messages named INBOUND_MESSAGE and sends messages
30  * named OUTBOUND_MESSAGE.  The data of each message is expected to look like
31  * { type, data }.  type is the message's type (or subtype if you consider the
32  * type of the message itself to be INBOUND_MESSAGE), and data is data that is
33  * specific to the type.
34  *
35  * Inbound messages have the following types:
36  *
37  *   AddFormHistoryEntry
38  *     Adds an entry to the search form history.
39  *     data: the entry, a string
40  *   GetSuggestions
41  *     Retrieves an array of search suggestions given a search string.
42  *     data: { engineName, searchString, [remoteTimeout] }
43  *   GetState
44  *     Retrieves the current search engine state.
45  *     data: null
46  *   ManageEngines
47  *     Opens the search engine management window.
48  *     data: null
49  *   RemoveFormHistoryEntry
50  *     Removes an entry from the search form history.
51  *     data: the entry, a string
52  *   Search
53  *     Performs a search.
54  *     data: { engineName, searchString, whence }
55  *   SetCurrentEngine
56  *     Sets the current engine.
57  *     data: the name of the engine
58  *   SpeculativeConnect
59  *     Speculatively connects to an engine.
60  *     data: the name of the engine
61  *
62  * Outbound messages have the following types:
63  *
64  *   CurrentEngine
65  *     Broadcast when the current engine changes.
66  *     data: see _currentEngineObj
67  *   CurrentState
68  *     Broadcast when the current search state changes.
69  *     data: see _currentStateObj
70  *   State
71  *     Sent in reply to GetState.
72  *     data: see _currentStateObj
73  *   Suggestions
74  *     Sent in reply to GetSuggestions.
75  *     data: see _onMessageGetSuggestions
76  */
78 this.ContentSearch = {
80   // Inbound events are queued and processed in FIFO order instead of handling
81   // them immediately, which would result in non-FIFO responses due to the
82   // asynchrononicity added by converting image data URIs to ArrayBuffers.
83   _eventQueue: [],
84   _currentEventPromise: null,
86   // This is used to handle search suggestions.  It maps xul:browsers to objects
87   // { controller, previousFormHistoryResult }.  See _onMessageGetSuggestions.
88   _suggestionMap: new WeakMap(),
90   // Resolved when we finish shutting down.
91   _destroyedPromise: null,
93   init: function () {
94     Cc["@mozilla.org/globalmessagemanager;1"].
95       getService(Ci.nsIMessageListenerManager).
96       addMessageListener(INBOUND_MESSAGE, this);
97     Services.obs.addObserver(this, "browser-search-engine-modified", false);
98     Services.obs.addObserver(this, "shutdown-leaks-before-check", false);
99     this._stringBundle = Services.strings.createBundle("chrome://global/locale/autocomplete.properties");
100   },
102   destroy: function () {
103     if (this._destroyedPromise) {
104       return this._destroyedPromise;
105     }
107     Cc["@mozilla.org/globalmessagemanager;1"].
108       getService(Ci.nsIMessageListenerManager).
109       removeMessageListener(INBOUND_MESSAGE, this);
110     Services.obs.removeObserver(this, "browser-search-engine-modified");
111     Services.obs.removeObserver(this, "shutdown-leaks-before-check");
113     this._eventQueue.length = 0;
114     return this._destroyedPromise = Promise.resolve(this._currentEventPromise);
115   },
117   /**
118    * Focuses the search input in the page with the given message manager.
119    * @param  messageManager
120    *         The MessageManager object of the selected browser.
121    */
122   focusInput: function (messageManager) {
123     messageManager.sendAsyncMessage(OUTBOUND_MESSAGE, {
124       type: "FocusInput"
125     });
126   },
128   receiveMessage: function (msg) {
129     // Add a temporary event handler that exists only while the message is in
130     // the event queue.  If the message's source docshell changes browsers in
131     // the meantime, then we need to update msg.target.  event.detail will be
132     // the docshell's new parent <xul:browser> element.
133     msg.handleEvent = event => {
134       let browserData = this._suggestionMap.get(msg.target);
135       if (browserData) {
136         this._suggestionMap.delete(msg.target);
137         this._suggestionMap.set(event.detail, browserData);
138       }
139       msg.target.removeEventListener("SwapDocShells", msg, true);
140       msg.target = event.detail;
141       msg.target.addEventListener("SwapDocShells", msg, true);
142     };
143     msg.target.addEventListener("SwapDocShells", msg, true);
145     this._eventQueue.push({
146       type: "Message",
147       data: msg,
148     });
149     this._processEventQueue();
150   },
152   observe: function (subj, topic, data) {
153     switch (topic) {
154     case "browser-search-engine-modified":
155       this._eventQueue.push({
156         type: "Observe",
157         data: data,
158       });
159       this._processEventQueue();
160       break;
161     case "shutdown-leaks-before-check":
162       subj.wrappedJSObject.client.addBlocker(
163         "ContentSearch: Wait until the service is destroyed", () => this.destroy());
164       break;
165     }
166   },
168   _processEventQueue: function () {
169     if (this._currentEventPromise || !this._eventQueue.length) {
170       return;
171     }
173     let event = this._eventQueue.shift();
175     return this._currentEventPromise = Task.spawn(function* () {
176       try {
177         yield this["_on" + event.type](event.data);
178       } catch (err) {
179         Cu.reportError(err);
180       } finally {
181         this._currentEventPromise = null;
182         this._processEventQueue();
183       }
184     }.bind(this));
185   },
187   _onMessage: Task.async(function* (msg) {
188     let methodName = "_onMessage" + msg.data.type;
189     if (methodName in this) {
190       yield this._initService();
191       yield this[methodName](msg, msg.data.data);
192       msg.target.removeEventListener("SwapDocShells", msg, true);
193     }
194   }),
196   _onMessageGetState: function (msg, data) {
197     return this._currentStateObj().then(state => {
198       this._reply(msg, "State", state);
199     });
200   },
202   _onMessageSearch: function (msg, data) {
203     this._ensureDataHasProperties(data, [
204       "engineName",
205       "searchString",
206       "whence",
207     ]);
208     let engine = Services.search.getEngineByName(data.engineName);
209     let submission = engine.getSubmission(data.searchString, "", data.whence);
210     let browser = msg.target;
211     try {
212       browser.loadURIWithFlags(submission.uri.spec,
213                                Ci.nsIWebNavigation.LOAD_FLAGS_NONE, null, null,
214                                submission.postData);
215     }
216     catch (err) {
217       // The browser may have been closed between the time its content sent the
218       // message and the time we handle it.  In that case, trying to call any
219       // method on it will throw.
220       return Promise.resolve();
221     }
222     let win = browser.ownerDocument.defaultView;
223     win.BrowserSearch.recordSearchInHealthReport(engine, data.whence,
224                                                  data.selection || null);
225     return Promise.resolve();
226   },
228   _onMessageSetCurrentEngine: function (msg, data) {
229     Services.search.currentEngine = Services.search.getEngineByName(data);
230     return Promise.resolve();
231   },
233   _onMessageManageEngines: function (msg, data) {
234     let browserWin = msg.target.ownerDocument.defaultView;
236     if (Services.prefs.getBoolPref("browser.search.showOneOffButtons")) {
237       browserWin.openPreferences("paneSearch");
238       return Promise.resolve();
239     }
241     let wm = Components.classes["@mozilla.org/appshell/window-mediator;1"].
242              getService(Components.interfaces.nsIWindowMediator);
243     let window = wm.getMostRecentWindow("Browser:SearchManager");
245     if (window) {
246       window.focus()
247     }
248     else {
249       browserWin.setTimeout(function () {
250         browserWin.openDialog("chrome://browser/content/search/engineManager.xul",
251           "_blank", "chrome,dialog,modal,centerscreen,resizable");
252       }, 0);
253     }
254     return Promise.resolve();
255   },
257   _onMessageGetSuggestions: Task.async(function* (msg, data) {
258     this._ensureDataHasProperties(data, [
259       "engineName",
260       "searchString",
261     ]);
263     let engine = Services.search.getEngineByName(data.engineName);
264     if (!engine) {
265       throw new Error("Unknown engine name: " + data.engineName);
266     }
268     let browserData = this._suggestionDataForBrowser(msg.target, true);
269     let { controller } = browserData;
270     let ok = SearchSuggestionController.engineOffersSuggestions(engine);
271     controller.maxLocalResults = ok ? 2 : 6;
272     controller.maxRemoteResults = ok ? 6 : 0;
273     controller.remoteTimeout = data.remoteTimeout || undefined;
274     let priv = PrivateBrowsingUtils.isBrowserPrivate(msg.target);
275     // fetch() rejects its promise if there's a pending request, but since we
276     // process our event queue serially, there's never a pending request.
277     let suggestions = yield controller.fetch(data.searchString, priv, engine);
279     // Keep the form history result so RemoveFormHistoryEntry can remove entries
280     // from it.  Keeping only one result isn't foolproof because the client may
281     // try to remove an entry from one set of suggestions after it has requested
282     // more but before it's received them.  In that case, the entry may not
283     // appear in the new suggestions.  But that should happen rarely.
284     browserData.previousFormHistoryResult = suggestions.formHistoryResult;
286     this._reply(msg, "Suggestions", {
287       engineName: data.engineName,
288       searchString: suggestions.term,
289       formHistory: suggestions.local,
290       remote: suggestions.remote,
291     });
292   }),
294   _onMessageAddFormHistoryEntry: function (msg, entry) {
295     let isPrivate = true;
296     try {
297       // isBrowserPrivate assumes that the passed-in browser has all the normal
298       // properties, which won't be true if the browser has been destroyed.
299       // That may be the case here due to the asynchronous nature of messaging.
300       isPrivate = PrivateBrowsingUtils.isBrowserPrivate(msg.target);
301     } catch (err) {}
302     if (isPrivate || entry === "") {
303       return Promise.resolve();
304     }
305     let browserData = this._suggestionDataForBrowser(msg.target, true);
306     FormHistory.update({
307       op: "bump",
308       fieldname: browserData.controller.formHistoryParam,
309       value: entry,
310     }, {
311       handleCompletion: () => {},
312       handleError: err => {
313         Cu.reportError("Error adding form history entry: " + err);
314       },
315     });
316     return Promise.resolve();
317   },
319   _onMessageRemoveFormHistoryEntry: function (msg, entry) {
320     let browserData = this._suggestionDataForBrowser(msg.target);
321     if (browserData && browserData.previousFormHistoryResult) {
322       let { previousFormHistoryResult } = browserData;
323       for (let i = 0; i < previousFormHistoryResult.matchCount; i++) {
324         if (previousFormHistoryResult.getValueAt(i) == entry) {
325           previousFormHistoryResult.removeValueAt(i, true);
326           break;
327         }
328       }
329     }
330     return Promise.resolve();
331   },
333   _onMessageSpeculativeConnect: function (msg, engineName) {
334     let engine = Services.search.getEngineByName(engineName);
335     if (!engine) {
336       throw new Error("Unknown engine name: " + engineName);
337     }
338     if (msg.target.contentWindow) {
339       engine.speculativeConnect({
340         window: msg.target.contentWindow,
341       });
342     }
343   },
345   _onObserve: Task.async(function* (data) {
346     if (data == "engine-current") {
347       let engine = yield this._currentEngineObj();
348       this._broadcast("CurrentEngine", engine);
349     }
350     else if (data != "engine-default") {
351       // engine-default is always sent with engine-current and isn't otherwise
352       // relevant to content searches.
353       let state = yield this._currentStateObj();
354       this._broadcast("CurrentState", state);
355     }
356   }),
358   _suggestionDataForBrowser: function (browser, create=false) {
359     let data = this._suggestionMap.get(browser);
360     if (!data && create) {
361       // Since one SearchSuggestionController instance is meant to be used per
362       // autocomplete widget, this means that we assume each xul:browser has at
363       // most one such widget.
364       data = {
365         controller: new SearchSuggestionController(),
366       };
367       this._suggestionMap.set(browser, data);
368     }
369     return data;
370   },
372   _reply: function (msg, type, data) {
373     // We reply asyncly to messages, and by the time we reply the browser we're
374     // responding to may have been destroyed.  messageManager is null then.
375     if (msg.target.messageManager) {
376       msg.target.messageManager.sendAsyncMessage(...this._msgArgs(type, data));
377     }
378   },
380   _broadcast: function (type, data) {
381     Cc["@mozilla.org/globalmessagemanager;1"].
382       getService(Ci.nsIMessageListenerManager).
383       broadcastAsyncMessage(...this._msgArgs(type, data));
384   },
386   _msgArgs: function (type, data) {
387     return [OUTBOUND_MESSAGE, {
388       type: type,
389       data: data,
390     }];
391   },
393   _currentStateObj: Task.async(function* () {
394     let state = {
395       engines: [],
396       currentEngine: yield this._currentEngineObj(),
397     };
398     for (let engine of Services.search.getVisibleEngines()) {
399       let uri = engine.getIconURLBySize(16, 16);
400       state.engines.push({
401         name: engine.name,
402         iconBuffer: yield this._arrayBufferFromDataURI(uri),
403       });
404     }
405     return state;
406   }),
408   _currentEngineObj: Task.async(function* () {
409     let engine = Services.search.currentEngine;
410     let favicon = engine.getIconURLBySize(16, 16);
411     let uri1x = engine.getIconURLBySize(65, 26);
412     let uri2x = engine.getIconURLBySize(130, 52);
413     let placeholder = this._stringBundle.formatStringFromName(
414       "searchWithEngine", [engine.name], 1);
415     let obj = {
416       name: engine.name,
417       placeholder: placeholder,
418       iconBuffer: yield this._arrayBufferFromDataURI(favicon),
419       logoBuffer: yield this._arrayBufferFromDataURI(uri1x),
420       logo2xBuffer: yield this._arrayBufferFromDataURI(uri2x),
421     };
422     return obj;
423   }),
425   _arrayBufferFromDataURI: function (uri) {
426     if (!uri) {
427       return Promise.resolve(null);
428     }
429     let deferred = Promise.defer();
430     let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
431               createInstance(Ci.nsIXMLHttpRequest);
432     xhr.open("GET", uri, true);
433     xhr.responseType = "arraybuffer";
434     xhr.onloadend = () => {
435       deferred.resolve(xhr.response);
436     };
437     try {
438       // This throws if the URI is erroneously encoded.
439       xhr.send();
440     }
441     catch (err) {
442       return Promise.resolve(null);
443     }
444     return deferred.promise;
445   },
447   _ensureDataHasProperties: function (data, requiredProperties) {
448     for (let prop of requiredProperties) {
449       if (!(prop in data)) {
450         throw new Error("Message data missing required property: " + prop);
451       }
452     }
453   },
455   _initService: function () {
456     if (!this._initServicePromise) {
457       let deferred = Promise.defer();
458       this._initServicePromise = deferred.promise;
459       Services.search.init(() => deferred.resolve());
460     }
461     return this._initServicePromise;
462   },