|
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 |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 const SEARCH_RESPONSE_SUGGESTION_JSON = "application/x-suggestions+json"; |
|
6 |
|
7 const BROWSER_SUGGEST_PREF = "browser.search.suggest.enabled"; |
|
8 const XPCOM_SHUTDOWN_TOPIC = "xpcom-shutdown"; |
|
9 const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; |
|
10 |
|
11 const Cc = Components.classes; |
|
12 const Ci = Components.interfaces; |
|
13 const Cr = Components.results; |
|
14 const Cu = Components.utils; |
|
15 |
|
16 const HTTP_OK = 200; |
|
17 const HTTP_INTERNAL_SERVER_ERROR = 500; |
|
18 const HTTP_BAD_GATEWAY = 502; |
|
19 const HTTP_SERVICE_UNAVAILABLE = 503; |
|
20 |
|
21 Cu.import("resource://gre/modules/XPCOMUtils.jsm"); |
|
22 Cu.import("resource://gre/modules/nsFormAutoCompleteResult.jsm"); |
|
23 Cu.import("resource://gre/modules/Services.jsm"); |
|
24 |
|
25 /** |
|
26 * SuggestAutoComplete is a base class that implements nsIAutoCompleteSearch |
|
27 * and can collect results for a given search by using the search URL supplied |
|
28 * by the subclass. We do it this way since the AutoCompleteController in |
|
29 * Mozilla requires a unique XPCOM Service for every search provider, even if |
|
30 * the logic for two providers is identical. |
|
31 * @constructor |
|
32 */ |
|
33 function SuggestAutoComplete() { |
|
34 this._init(); |
|
35 } |
|
36 SuggestAutoComplete.prototype = { |
|
37 |
|
38 _init: function() { |
|
39 this._addObservers(); |
|
40 this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF); |
|
41 }, |
|
42 |
|
43 get _suggestionLabel() { |
|
44 delete this._suggestionLabel; |
|
45 let bundle = Services.strings.createBundle("chrome://global/locale/search/search.properties"); |
|
46 return this._suggestionLabel = bundle.GetStringFromName("suggestion_label"); |
|
47 }, |
|
48 |
|
49 /** |
|
50 * Search suggestions will be shown if this._suggestEnabled is true. |
|
51 */ |
|
52 _suggestEnabled: null, |
|
53 |
|
54 /************************************************************************* |
|
55 * Server request backoff implementation fields below |
|
56 * These allow us to throttle requests if the server is getting hammered. |
|
57 **************************************************************************/ |
|
58 |
|
59 /** |
|
60 * This is an array that contains the timestamps (in unixtime) of |
|
61 * the last few backoff-triggering errors. |
|
62 */ |
|
63 _serverErrorLog: [], |
|
64 |
|
65 /** |
|
66 * If we receive this number of backoff errors within the amount of time |
|
67 * specified by _serverErrorPeriod, then we initiate backoff. |
|
68 */ |
|
69 _maxErrorsBeforeBackoff: 3, |
|
70 |
|
71 /** |
|
72 * If we receive enough consecutive errors (where "enough" is defined by |
|
73 * _maxErrorsBeforeBackoff above) within this time period, |
|
74 * we trigger the backoff behavior. |
|
75 */ |
|
76 _serverErrorPeriod: 600000, // 10 minutes in milliseconds |
|
77 |
|
78 /** |
|
79 * If we get another backoff error immediately after timeout, we increase the |
|
80 * backoff to (2 x old period) + this value. |
|
81 */ |
|
82 _serverErrorTimeoutIncrement: 600000, // 10 minutes in milliseconds |
|
83 |
|
84 /** |
|
85 * The current amount of time to wait before trying a server request |
|
86 * after receiving a backoff error. |
|
87 */ |
|
88 _serverErrorTimeout: 0, |
|
89 |
|
90 /** |
|
91 * Time (in unixtime) after which we're allowed to try requesting again. |
|
92 */ |
|
93 _nextRequestTime: 0, |
|
94 |
|
95 /** |
|
96 * The last engine we requested against (so that we can tell if the |
|
97 * user switched engines). |
|
98 */ |
|
99 _serverErrorEngine: null, |
|
100 |
|
101 /** |
|
102 * The XMLHttpRequest object. |
|
103 * @private |
|
104 */ |
|
105 _request: null, |
|
106 |
|
107 /** |
|
108 * The object implementing nsIAutoCompleteObserver that we notify when |
|
109 * we have found results |
|
110 * @private |
|
111 */ |
|
112 _listener: null, |
|
113 |
|
114 /** |
|
115 * If this is true, we'll integrate form history results with the |
|
116 * suggest results. |
|
117 */ |
|
118 _includeFormHistory: true, |
|
119 |
|
120 /** |
|
121 * True if a request for remote suggestions was sent. This is used to |
|
122 * differentiate between the "_request is null because the request has |
|
123 * already returned a result" and "_request is null because no request was |
|
124 * sent" cases. |
|
125 */ |
|
126 _sentSuggestRequest: false, |
|
127 |
|
128 /** |
|
129 * This is the callback for the suggest timeout timer. |
|
130 */ |
|
131 notify: function SAC_notify(timer) { |
|
132 // FIXME: bug 387341 |
|
133 // Need to break the cycle between us and the timer. |
|
134 this._formHistoryTimer = null; |
|
135 |
|
136 // If this._listener is null, we've already sent out suggest results, so |
|
137 // nothing left to do here. |
|
138 if (!this._listener) |
|
139 return; |
|
140 |
|
141 // Otherwise, the XMLHTTPRequest for suggest results is taking too long, |
|
142 // so send out the form history results and cancel the request. |
|
143 this._listener.onSearchResult(this, this._formHistoryResult); |
|
144 this._reset(); |
|
145 }, |
|
146 |
|
147 /** |
|
148 * This determines how long (in ms) we should wait before giving up on |
|
149 * the suggestions and just showing local form history results. |
|
150 */ |
|
151 _suggestionTimeout: 500, |
|
152 |
|
153 /** |
|
154 * This is the callback for that the form history service uses to |
|
155 * send us results. |
|
156 */ |
|
157 onSearchResult: function SAC_onSearchResult(search, result) { |
|
158 this._formHistoryResult = result; |
|
159 |
|
160 if (this._request) { |
|
161 // We still have a pending request, wait a bit to give it a chance to |
|
162 // finish. |
|
163 this._formHistoryTimer = Cc["@mozilla.org/timer;1"]. |
|
164 createInstance(Ci.nsITimer); |
|
165 this._formHistoryTimer.initWithCallback(this, this._suggestionTimeout, |
|
166 Ci.nsITimer.TYPE_ONE_SHOT); |
|
167 } else if (!this._sentSuggestRequest) { |
|
168 // We didn't send a request, so just send back the form history results. |
|
169 this._listener.onSearchResult(this, this._formHistoryResult); |
|
170 this._reset(); |
|
171 } |
|
172 }, |
|
173 |
|
174 /** |
|
175 * This is the URI that the last suggest request was sent to. |
|
176 */ |
|
177 _suggestURI: null, |
|
178 |
|
179 /** |
|
180 * Autocomplete results from the form history service get stored here. |
|
181 */ |
|
182 _formHistoryResult: null, |
|
183 |
|
184 /** |
|
185 * This holds the suggest server timeout timer, if applicable. |
|
186 */ |
|
187 _formHistoryTimer: null, |
|
188 |
|
189 /** |
|
190 * Maximum number of history items displayed. This is capped at 7 |
|
191 * because the primary consumer (Firefox search bar) displays 10 rows |
|
192 * by default, and so we want to leave some space for suggestions |
|
193 * to be visible. |
|
194 */ |
|
195 _historyLimit: 7, |
|
196 |
|
197 /** |
|
198 * This clears all the per-request state. |
|
199 */ |
|
200 _reset: function SAC_reset() { |
|
201 // Don't let go of our listener and form history result if the timer is |
|
202 // still pending, the timer will call _reset() when it fires. |
|
203 if (!this._formHistoryTimer) { |
|
204 this._listener = null; |
|
205 this._formHistoryResult = null; |
|
206 } |
|
207 this._request = null; |
|
208 }, |
|
209 |
|
210 /** |
|
211 * This sends an autocompletion request to the form history service, |
|
212 * which will call onSearchResults with the results of the query. |
|
213 */ |
|
214 _startHistorySearch: function SAC_SHSearch(searchString, searchParam) { |
|
215 var formHistory = |
|
216 Cc["@mozilla.org/autocomplete/search;1?name=form-history"]. |
|
217 createInstance(Ci.nsIAutoCompleteSearch); |
|
218 formHistory.startSearch(searchString, searchParam, this._formHistoryResult, this); |
|
219 }, |
|
220 |
|
221 /** |
|
222 * Makes a note of the fact that we've received a backoff-triggering |
|
223 * response, so that we can adjust the backoff behavior appropriately. |
|
224 */ |
|
225 _noteServerError: function SAC__noteServeError() { |
|
226 var currentTime = Date.now(); |
|
227 |
|
228 this._serverErrorLog.push(currentTime); |
|
229 if (this._serverErrorLog.length > this._maxErrorsBeforeBackoff) |
|
230 this._serverErrorLog.shift(); |
|
231 |
|
232 if ((this._serverErrorLog.length == this._maxErrorsBeforeBackoff) && |
|
233 ((currentTime - this._serverErrorLog[0]) < this._serverErrorPeriod)) { |
|
234 // increase timeout, and then don't request until timeout is over |
|
235 this._serverErrorTimeout = (this._serverErrorTimeout * 2) + |
|
236 this._serverErrorTimeoutIncrement; |
|
237 this._nextRequestTime = currentTime + this._serverErrorTimeout; |
|
238 } |
|
239 }, |
|
240 |
|
241 /** |
|
242 * Resets the backoff behavior; called when we get a successful response. |
|
243 */ |
|
244 _clearServerErrors: function SAC__clearServerErrors() { |
|
245 this._serverErrorLog = []; |
|
246 this._serverErrorTimeout = 0; |
|
247 this._nextRequestTime = 0; |
|
248 }, |
|
249 |
|
250 /** |
|
251 * This checks whether we should send a server request (i.e. we're not |
|
252 * in a error-triggered backoff period. |
|
253 * |
|
254 * @private |
|
255 */ |
|
256 _okToRequest: function SAC__okToRequest() { |
|
257 return Date.now() > this._nextRequestTime; |
|
258 }, |
|
259 |
|
260 /** |
|
261 * This checks to see if the new search engine is different |
|
262 * from the previous one, and if so clears any error state that might |
|
263 * have accumulated for the old engine. |
|
264 * |
|
265 * @param engine The engine that the suggestion request would be sent to. |
|
266 * @private |
|
267 */ |
|
268 _checkForEngineSwitch: function SAC__checkForEngineSwitch(engine) { |
|
269 if (engine == this._serverErrorEngine) |
|
270 return; |
|
271 |
|
272 // must've switched search providers, clear old errors |
|
273 this._serverErrorEngine = engine; |
|
274 this._clearServerErrors(); |
|
275 }, |
|
276 |
|
277 /** |
|
278 * This returns true if the status code of the HTTP response |
|
279 * represents a backoff-triggering error. |
|
280 * |
|
281 * @param status The status code from the HTTP response |
|
282 * @private |
|
283 */ |
|
284 _isBackoffError: function SAC__isBackoffError(status) { |
|
285 return ((status == HTTP_INTERNAL_SERVER_ERROR) || |
|
286 (status == HTTP_BAD_GATEWAY) || |
|
287 (status == HTTP_SERVICE_UNAVAILABLE)); |
|
288 }, |
|
289 |
|
290 /** |
|
291 * Called when the 'readyState' of the XMLHttpRequest changes. We only care |
|
292 * about state 4 (COMPLETED) - handle the response data. |
|
293 * @private |
|
294 */ |
|
295 onReadyStateChange: function() { |
|
296 // xxx use the real const here |
|
297 if (!this._request || this._request.readyState != 4) |
|
298 return; |
|
299 |
|
300 try { |
|
301 var status = this._request.status; |
|
302 } catch (e) { |
|
303 // The XML HttpRequest can throw NS_ERROR_NOT_AVAILABLE. |
|
304 return; |
|
305 } |
|
306 |
|
307 if (this._isBackoffError(status)) { |
|
308 this._noteServerError(); |
|
309 return; |
|
310 } |
|
311 |
|
312 var responseText = this._request.responseText; |
|
313 if (status != HTTP_OK || responseText == "") |
|
314 return; |
|
315 |
|
316 this._clearServerErrors(); |
|
317 |
|
318 try { |
|
319 var serverResults = JSON.parse(responseText); |
|
320 } catch(ex) { |
|
321 Components.utils.reportError("Failed to parse JSON from " + this._suggestURI.spec + ": " + ex); |
|
322 return; |
|
323 } |
|
324 |
|
325 var searchString = serverResults[0] || ""; |
|
326 var results = serverResults[1] || []; |
|
327 |
|
328 var comments = []; // "comments" column values for suggestions |
|
329 var historyResults = []; |
|
330 var historyComments = []; |
|
331 |
|
332 // If form history is enabled and has results, add them to the list. |
|
333 if (this._includeFormHistory && this._formHistoryResult && |
|
334 (this._formHistoryResult.searchResult == |
|
335 Ci.nsIAutoCompleteResult.RESULT_SUCCESS)) { |
|
336 var maxHistoryItems = Math.min(this._formHistoryResult.matchCount, this._historyLimit); |
|
337 for (var i = 0; i < maxHistoryItems; ++i) { |
|
338 var term = this._formHistoryResult.getValueAt(i); |
|
339 |
|
340 // we don't want things to appear in both history and suggestions |
|
341 var dupIndex = results.indexOf(term); |
|
342 if (dupIndex != -1) |
|
343 results.splice(dupIndex, 1); |
|
344 |
|
345 historyResults.push(term); |
|
346 historyComments.push(""); |
|
347 } |
|
348 } |
|
349 |
|
350 // fill out the comment column for the suggestions |
|
351 for (var i = 0; i < results.length; ++i) |
|
352 comments.push(""); |
|
353 |
|
354 // if we have any suggestions, put a label at the top |
|
355 if (comments.length > 0) |
|
356 comments[0] = this._suggestionLabel; |
|
357 |
|
358 // now put the history results above the suggestions |
|
359 var finalResults = historyResults.concat(results); |
|
360 var finalComments = historyComments.concat(comments); |
|
361 |
|
362 // Notify the FE of our new results |
|
363 this.onResultsReady(searchString, finalResults, finalComments, |
|
364 this._formHistoryResult); |
|
365 |
|
366 // Reset our state for next time. |
|
367 this._reset(); |
|
368 }, |
|
369 |
|
370 /** |
|
371 * Notifies the front end of new results. |
|
372 * @param searchString the user's query string |
|
373 * @param results an array of results to the search |
|
374 * @param comments an array of metadata corresponding to the results |
|
375 * @private |
|
376 */ |
|
377 onResultsReady: function(searchString, results, comments, |
|
378 formHistoryResult) { |
|
379 if (this._listener) { |
|
380 var result = new FormAutoCompleteResult( |
|
381 searchString, |
|
382 Ci.nsIAutoCompleteResult.RESULT_SUCCESS, |
|
383 0, |
|
384 "", |
|
385 results, |
|
386 results, |
|
387 comments, |
|
388 formHistoryResult); |
|
389 |
|
390 this._listener.onSearchResult(this, result); |
|
391 |
|
392 // Null out listener to make sure we don't notify it twice, in case our |
|
393 // timer callback still hasn't run. |
|
394 this._listener = null; |
|
395 } |
|
396 }, |
|
397 |
|
398 /** |
|
399 * Initiates the search result gathering process. Part of |
|
400 * nsIAutoCompleteSearch implementation. |
|
401 * |
|
402 * @param searchString the user's query string |
|
403 * @param searchParam unused, "an extra parameter"; even though |
|
404 * this parameter and the next are unused, pass |
|
405 * them through in case the form history |
|
406 * service wants them |
|
407 * @param previousResult unused, a client-cached store of the previous |
|
408 * generated resultset for faster searching. |
|
409 * @param listener object implementing nsIAutoCompleteObserver which |
|
410 * we notify when results are ready. |
|
411 */ |
|
412 startSearch: function(searchString, searchParam, previousResult, listener) { |
|
413 // Don't reuse a previous form history result when it no longer applies. |
|
414 if (!previousResult) |
|
415 this._formHistoryResult = null; |
|
416 |
|
417 var formHistorySearchParam = searchParam.split("|")[0]; |
|
418 |
|
419 // Receive the information about the privacy mode of the window to which |
|
420 // this search box belongs. The front-end's search.xml bindings passes this |
|
421 // information in the searchParam parameter. The alternative would have |
|
422 // been to modify nsIAutoCompleteSearch to add an argument to startSearch |
|
423 // and patch all of autocomplete to be aware of this, but the searchParam |
|
424 // argument is already an opaque argument, so this solution is hopefully |
|
425 // less hackish (although still gross.) |
|
426 var privacyMode = (searchParam.split("|")[1] == "private"); |
|
427 |
|
428 // Start search immediately if possible, otherwise once the search |
|
429 // service is initialized |
|
430 if (Services.search.isInitialized) { |
|
431 this._triggerSearch(searchString, formHistorySearchParam, listener, privacyMode); |
|
432 return; |
|
433 } |
|
434 |
|
435 Services.search.init((function startSearch_cb(aResult) { |
|
436 if (!Components.isSuccessCode(aResult)) { |
|
437 Cu.reportError("Could not initialize search service, bailing out: " + aResult); |
|
438 return; |
|
439 } |
|
440 this._triggerSearch(searchString, formHistorySearchParam, listener, privacyMode); |
|
441 }).bind(this)); |
|
442 }, |
|
443 |
|
444 /** |
|
445 * Actual implementation of search. |
|
446 */ |
|
447 _triggerSearch: function(searchString, searchParam, listener, privacyMode) { |
|
448 // If there's an existing request, stop it. There is no smart filtering |
|
449 // here as there is when looking through history/form data because the |
|
450 // result set returned by the server is different for every typed value - |
|
451 // "ocean breathes" does not return a subset of the results returned for |
|
452 // "ocean", for example. This does nothing if there is no current request. |
|
453 this.stopSearch(); |
|
454 |
|
455 this._listener = listener; |
|
456 |
|
457 var engine = Services.search.currentEngine; |
|
458 |
|
459 this._checkForEngineSwitch(engine); |
|
460 |
|
461 if (!searchString || |
|
462 !this._suggestEnabled || |
|
463 !engine.supportsResponseType(SEARCH_RESPONSE_SUGGESTION_JSON) || |
|
464 !this._okToRequest()) { |
|
465 // We have an empty search string (user pressed down arrow to see |
|
466 // history), or search suggestions are disabled, or the current engine |
|
467 // has no suggest functionality, or we're in backoff mode; so just use |
|
468 // local history. |
|
469 this._sentSuggestRequest = false; |
|
470 this._startHistorySearch(searchString, searchParam); |
|
471 return; |
|
472 } |
|
473 |
|
474 // Actually do the search |
|
475 this._request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. |
|
476 createInstance(Ci.nsIXMLHttpRequest); |
|
477 var submission = engine.getSubmission(searchString, |
|
478 SEARCH_RESPONSE_SUGGESTION_JSON); |
|
479 this._suggestURI = submission.uri; |
|
480 var method = (submission.postData ? "POST" : "GET"); |
|
481 this._request.open(method, this._suggestURI.spec, true); |
|
482 this._request.channel.notificationCallbacks = new AuthPromptOverride(); |
|
483 if (this._request.channel instanceof Ci.nsIPrivateBrowsingChannel) { |
|
484 this._request.channel.setPrivate(privacyMode); |
|
485 } |
|
486 |
|
487 var self = this; |
|
488 function onReadyStateChange() { |
|
489 self.onReadyStateChange(); |
|
490 } |
|
491 this._request.onreadystatechange = onReadyStateChange; |
|
492 this._request.send(submission.postData); |
|
493 |
|
494 if (this._includeFormHistory) { |
|
495 this._sentSuggestRequest = true; |
|
496 this._startHistorySearch(searchString, searchParam); |
|
497 } |
|
498 }, |
|
499 |
|
500 /** |
|
501 * Ends the search result gathering process. Part of nsIAutoCompleteSearch |
|
502 * implementation. |
|
503 */ |
|
504 stopSearch: function() { |
|
505 if (this._request) { |
|
506 this._request.abort(); |
|
507 this._reset(); |
|
508 } |
|
509 }, |
|
510 |
|
511 /** |
|
512 * nsIObserver |
|
513 */ |
|
514 observe: function SAC_observe(aSubject, aTopic, aData) { |
|
515 switch (aTopic) { |
|
516 case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID: |
|
517 this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF); |
|
518 break; |
|
519 case XPCOM_SHUTDOWN_TOPIC: |
|
520 this._removeObservers(); |
|
521 break; |
|
522 } |
|
523 }, |
|
524 |
|
525 _addObservers: function SAC_addObservers() { |
|
526 Services.prefs.addObserver(BROWSER_SUGGEST_PREF, this, false); |
|
527 |
|
528 Services.obs.addObserver(this, XPCOM_SHUTDOWN_TOPIC, false); |
|
529 }, |
|
530 |
|
531 _removeObservers: function SAC_removeObservers() { |
|
532 Services.prefs.removeObserver(BROWSER_SUGGEST_PREF, this); |
|
533 |
|
534 Services.obs.removeObserver(this, XPCOM_SHUTDOWN_TOPIC); |
|
535 }, |
|
536 |
|
537 // nsISupports |
|
538 QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteSearch, |
|
539 Ci.nsIAutoCompleteObserver]) |
|
540 }; |
|
541 |
|
542 function AuthPromptOverride() { |
|
543 } |
|
544 AuthPromptOverride.prototype = { |
|
545 // nsIAuthPromptProvider |
|
546 getAuthPrompt: function (reason, iid) { |
|
547 // Return a no-op nsIAuthPrompt2 implementation. |
|
548 return { |
|
549 promptAuth: function () { |
|
550 throw Cr.NS_ERROR_NOT_IMPLEMENTED; |
|
551 }, |
|
552 asyncPromptAuth: function () { |
|
553 throw Cr.NS_ERROR_NOT_IMPLEMENTED; |
|
554 } |
|
555 }; |
|
556 }, |
|
557 |
|
558 // nsIInterfaceRequestor |
|
559 getInterface: function SSLL_getInterface(iid) { |
|
560 return this.QueryInterface(iid); |
|
561 }, |
|
562 |
|
563 // nsISupports |
|
564 QueryInterface: XPCOMUtils.generateQI([Ci.nsIAuthPromptProvider, |
|
565 Ci.nsIInterfaceRequestor]) |
|
566 }; |
|
567 /** |
|
568 * SearchSuggestAutoComplete is a service implementation that handles suggest |
|
569 * results specific to web searches. |
|
570 * @constructor |
|
571 */ |
|
572 function SearchSuggestAutoComplete() { |
|
573 // This calls _init() in the parent class (SuggestAutoComplete) via the |
|
574 // prototype, below. |
|
575 this._init(); |
|
576 } |
|
577 SearchSuggestAutoComplete.prototype = { |
|
578 classID: Components.ID("{aa892eb4-ffbf-477d-9f9a-06c995ae9f27}"), |
|
579 __proto__: SuggestAutoComplete.prototype, |
|
580 serviceURL: "" |
|
581 }; |
|
582 |
|
583 var component = [SearchSuggestAutoComplete]; |
|
584 this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component); |