Bumping gaia.json for 1 gaia revision(s) a=gaia-bump
[gecko.git] / browser / modules / ContentSearch.jsm
blobe02f1a3325091e73d300aff1becb073917446afc
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   init: function () {
91     Cc["@mozilla.org/globalmessagemanager;1"].
92       getService(Ci.nsIMessageListenerManager).
93       addMessageListener(INBOUND_MESSAGE, this);
94     Services.obs.addObserver(this, "browser-search-engine-modified", false);
95   },
97   destroy: function () {
98     Cc["@mozilla.org/globalmessagemanager;1"].
99       getService(Ci.nsIMessageListenerManager).
100       removeMessageListener(INBOUND_MESSAGE, this);
101     Services.obs.removeObserver(this, "browser-search-engine-modified");
103     this._eventQueue.length = 0;
104     return Promise.resolve(this._currentEventPromise);
105   },
107   /**
108    * Focuses the search input in the page with the given message manager.
109    * @param  messageManager
110    *         The MessageManager object of the selected browser.
111    */
112   focusInput: function (messageManager) {
113     messageManager.sendAsyncMessage(OUTBOUND_MESSAGE, {
114       type: "FocusInput"
115     });
116   },
118   receiveMessage: function (msg) {
119     // Add a temporary event handler that exists only while the message is in
120     // the event queue.  If the message's source docshell changes browsers in
121     // the meantime, then we need to update msg.target.  event.detail will be
122     // the docshell's new parent <xul:browser> element.
123     msg.handleEvent = event => {
124       let browserData = this._suggestionMap.get(msg.target);
125       if (browserData) {
126         this._suggestionMap.delete(msg.target);
127         this._suggestionMap.set(event.detail, browserData);
128       }
129       msg.target.removeEventListener("SwapDocShells", msg, true);
130       msg.target = event.detail;
131       msg.target.addEventListener("SwapDocShells", msg, true);
132     };
133     msg.target.addEventListener("SwapDocShells", msg, true);
135     this._eventQueue.push({
136       type: "Message",
137       data: msg,
138     });
139     this._processEventQueue();
140   },
142   observe: function (subj, topic, data) {
143     switch (topic) {
144     case "browser-search-engine-modified":
145       this._eventQueue.push({
146         type: "Observe",
147         data: data,
148       });
149       this._processEventQueue();
150       break;
151     }
152   },
154   _processEventQueue: function () {
155     if (this._currentEventPromise || !this._eventQueue.length) {
156       return;
157     }
159     let event = this._eventQueue.shift();
161     return this._currentEventPromise = Task.spawn(function* () {
162       try {
163         yield this["_on" + event.type](event.data);
164       } catch (err) {
165         Cu.reportError(err);
166       } finally {
167         this._currentEventPromise = null;
168         this._processEventQueue();
169       }
170     }.bind(this));
171   },
173   _onMessage: Task.async(function* (msg) {
174     let methodName = "_onMessage" + msg.data.type;
175     if (methodName in this) {
176       yield this._initService();
177       yield this[methodName](msg, msg.data.data);
178       msg.target.removeEventListener("SwapDocShells", msg, true);
179     }
180   }),
182   _onMessageGetState: function (msg, data) {
183     return this._currentStateObj().then(state => {
184       this._reply(msg, "State", state);
185     });
186   },
188   _onMessageSearch: function (msg, data) {
189     this._ensureDataHasProperties(data, [
190       "engineName",
191       "searchString",
192       "whence",
193     ]);
194     let browserWin = msg.target.ownerDocument.defaultView;
195     let engine = Services.search.getEngineByName(data.engineName);
196     browserWin.BrowserSearch.recordSearchInHealthReport(engine, data.whence);
197     let submission = engine.getSubmission(data.searchString, "", data.whence);
198     browserWin.loadURI(submission.uri.spec, null, submission.postData);
199     return Promise.resolve();
200   },
202   _onMessageSetCurrentEngine: function (msg, data) {
203     Services.search.currentEngine = Services.search.getEngineByName(data);
204     return Promise.resolve();
205   },
207   _onMessageManageEngines: function (msg, data) {
208     let browserWin = msg.target.ownerDocument.defaultView;
210     if (Services.prefs.getBoolPref("browser.search.showOneOffButtons")) {
211       browserWin.openPreferences("paneSearch");
212       return Promise.resolve();
213     }
215     let wm = Components.classes["@mozilla.org/appshell/window-mediator;1"].
216              getService(Components.interfaces.nsIWindowMediator);
217     let window = wm.getMostRecentWindow("Browser:SearchManager");
219     if (window) {
220       window.focus()
221     }
222     else {
223       browserWin.setTimeout(function () {
224         browserWin.openDialog("chrome://browser/content/search/engineManager.xul",
225           "_blank", "chrome,dialog,modal,centerscreen,resizable");
226       }, 0);
227     }
228     return Promise.resolve();
229   },
231   _onMessageGetSuggestions: Task.async(function* (msg, data) {
232     this._ensureDataHasProperties(data, [
233       "engineName",
234       "searchString",
235     ]);
237     let engine = Services.search.getEngineByName(data.engineName);
238     if (!engine) {
239       throw new Error("Unknown engine name: " + data.engineName);
240     }
242     let browserData = this._suggestionDataForBrowser(msg.target, true);
243     let { controller } = browserData;
244     let ok = SearchSuggestionController.engineOffersSuggestions(engine);
245     controller.maxLocalResults = ok ? 2 : 6;
246     controller.maxRemoteResults = ok ? 6 : 0;
247     controller.remoteTimeout = data.remoteTimeout || undefined;
248     let priv = PrivateBrowsingUtils.isWindowPrivate(msg.target.contentWindow);
249     // fetch() rejects its promise if there's a pending request, but since we
250     // process our event queue serially, there's never a pending request.
251     let suggestions = yield controller.fetch(data.searchString, priv, engine);
253     // Keep the form history result so RemoveFormHistoryEntry can remove entries
254     // from it.  Keeping only one result isn't foolproof because the client may
255     // try to remove an entry from one set of suggestions after it has requested
256     // more but before it's received them.  In that case, the entry may not
257     // appear in the new suggestions.  But that should happen rarely.
258     browserData.previousFormHistoryResult = suggestions.formHistoryResult;
260     this._reply(msg, "Suggestions", {
261       engineName: data.engineName,
262       searchString: suggestions.term,
263       formHistory: suggestions.local,
264       remote: suggestions.remote,
265     });
266   }),
268   _onMessageAddFormHistoryEntry: function (msg, entry) {
269     let isPrivate = true;
270     try {
271       // isBrowserPrivate assumes that the passed-in browser has all the normal
272       // properties, which won't be true if the browser has been destroyed.
273       // That may be the case here due to the asynchronous nature of messaging.
274       isPrivate = PrivateBrowsingUtils.isBrowserPrivate(msg.target);
275     } catch (err) {}
276     if (isPrivate || entry === "") {
277       return Promise.resolve();
278     }
279     let browserData = this._suggestionDataForBrowser(msg.target, true);
280     FormHistory.update({
281       op: "bump",
282       fieldname: browserData.controller.formHistoryParam,
283       value: entry,
284     }, {
285       handleCompletion: () => {},
286       handleError: err => {
287         Cu.reportError("Error adding form history entry: " + err);
288       },
289     });
290     return Promise.resolve();
291   },
293   _onMessageRemoveFormHistoryEntry: function (msg, entry) {
294     let browserData = this._suggestionDataForBrowser(msg.target);
295     if (browserData && browserData.previousFormHistoryResult) {
296       let { previousFormHistoryResult } = browserData;
297       for (let i = 0; i < previousFormHistoryResult.matchCount; i++) {
298         if (previousFormHistoryResult.getValueAt(i) == entry) {
299           previousFormHistoryResult.removeValueAt(i, true);
300           break;
301         }
302       }
303     }
304     return Promise.resolve();
305   },
307   _onMessageSpeculativeConnect: function (msg, engineName) {
308     let engine = Services.search.getEngineByName(engineName);
309     if (!engine) {
310       throw new Error("Unknown engine name: " + engineName);
311     }
312     if (msg.target.contentWindow) {
313       engine.speculativeConnect({
314         window: msg.target.contentWindow,
315       });
316     }
317   },
319   _onObserve: Task.async(function* (data) {
320     if (data == "engine-current") {
321       let engine = yield this._currentEngineObj();
322       this._broadcast("CurrentEngine", engine);
323     }
324     else if (data != "engine-default") {
325       // engine-default is always sent with engine-current and isn't otherwise
326       // relevant to content searches.
327       let state = yield this._currentStateObj();
328       this._broadcast("CurrentState", state);
329     }
330   }),
332   _suggestionDataForBrowser: function (browser, create=false) {
333     let data = this._suggestionMap.get(browser);
334     if (!data && create) {
335       // Since one SearchSuggestionController instance is meant to be used per
336       // autocomplete widget, this means that we assume each xul:browser has at
337       // most one such widget.
338       data = {
339         controller: new SearchSuggestionController(),
340       };
341       this._suggestionMap.set(browser, data);
342     }
343     return data;
344   },
346   _reply: function (msg, type, data) {
347     // We reply asyncly to messages, and by the time we reply the browser we're
348     // responding to may have been destroyed.  messageManager is null then.
349     if (msg.target.messageManager) {
350       msg.target.messageManager.sendAsyncMessage(...this._msgArgs(type, data));
351     }
352   },
354   _broadcast: function (type, data) {
355     Cc["@mozilla.org/globalmessagemanager;1"].
356       getService(Ci.nsIMessageListenerManager).
357       broadcastAsyncMessage(...this._msgArgs(type, data));
358   },
360   _msgArgs: function (type, data) {
361     return [OUTBOUND_MESSAGE, {
362       type: type,
363       data: data,
364     }];
365   },
367   _currentStateObj: Task.async(function* () {
368     let state = {
369       engines: [],
370       currentEngine: yield this._currentEngineObj(),
371     };
372     for (let engine of Services.search.getVisibleEngines()) {
373       let uri = engine.getIconURLBySize(16, 16);
374       state.engines.push({
375         name: engine.name,
376         iconBuffer: yield this._arrayBufferFromDataURI(uri),
377       });
378     }
379     return state;
380   }),
382   _currentEngineObj: Task.async(function* () {
383     let engine = Services.search.currentEngine;
384     let uri1x = engine.getIconURLBySize(65, 26);
385     let uri2x = engine.getIconURLBySize(130, 52);
386     let obj = {
387       name: engine.name,
388       logoBuffer: yield this._arrayBufferFromDataURI(uri1x),
389       logo2xBuffer: yield this._arrayBufferFromDataURI(uri2x),
390     };
391     return obj;
392   }),
394   _arrayBufferFromDataURI: function (uri) {
395     if (!uri) {
396       return Promise.resolve(null);
397     }
398     let deferred = Promise.defer();
399     let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
400               createInstance(Ci.nsIXMLHttpRequest);
401     xhr.open("GET", uri, true);
402     xhr.responseType = "arraybuffer";
403     xhr.onloadend = () => {
404       deferred.resolve(xhr.response);
405     };
406     try {
407       // This throws if the URI is erroneously encoded.
408       xhr.send();
409     }
410     catch (err) {
411       return Promise.resolve(null);
412     }
413     return deferred.promise;
414   },
416   _ensureDataHasProperties: function (data, requiredProperties) {
417     for (let prop of requiredProperties) {
418       if (!(prop in data)) {
419         throw new Error("Message data missing required property: " + prop);
420       }
421     }
422   },
424   _initService: function () {
425     if (!this._initServicePromise) {
426       let deferred = Promise.defer();
427       this._initServicePromise = deferred.promise;
428       Services.search.init(() => deferred.resolve());
429     }
430     return this._initServicePromise;
431   },