1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/toolkit/components/search/nsSearchSuggestions.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,584 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +const SEARCH_RESPONSE_SUGGESTION_JSON = "application/x-suggestions+json"; 1.9 + 1.10 +const BROWSER_SUGGEST_PREF = "browser.search.suggest.enabled"; 1.11 +const XPCOM_SHUTDOWN_TOPIC = "xpcom-shutdown"; 1.12 +const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; 1.13 + 1.14 +const Cc = Components.classes; 1.15 +const Ci = Components.interfaces; 1.16 +const Cr = Components.results; 1.17 +const Cu = Components.utils; 1.18 + 1.19 +const HTTP_OK = 200; 1.20 +const HTTP_INTERNAL_SERVER_ERROR = 500; 1.21 +const HTTP_BAD_GATEWAY = 502; 1.22 +const HTTP_SERVICE_UNAVAILABLE = 503; 1.23 + 1.24 +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); 1.25 +Cu.import("resource://gre/modules/nsFormAutoCompleteResult.jsm"); 1.26 +Cu.import("resource://gre/modules/Services.jsm"); 1.27 + 1.28 +/** 1.29 + * SuggestAutoComplete is a base class that implements nsIAutoCompleteSearch 1.30 + * and can collect results for a given search by using the search URL supplied 1.31 + * by the subclass. We do it this way since the AutoCompleteController in 1.32 + * Mozilla requires a unique XPCOM Service for every search provider, even if 1.33 + * the logic for two providers is identical. 1.34 + * @constructor 1.35 + */ 1.36 +function SuggestAutoComplete() { 1.37 + this._init(); 1.38 +} 1.39 +SuggestAutoComplete.prototype = { 1.40 + 1.41 + _init: function() { 1.42 + this._addObservers(); 1.43 + this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF); 1.44 + }, 1.45 + 1.46 + get _suggestionLabel() { 1.47 + delete this._suggestionLabel; 1.48 + let bundle = Services.strings.createBundle("chrome://global/locale/search/search.properties"); 1.49 + return this._suggestionLabel = bundle.GetStringFromName("suggestion_label"); 1.50 + }, 1.51 + 1.52 + /** 1.53 + * Search suggestions will be shown if this._suggestEnabled is true. 1.54 + */ 1.55 + _suggestEnabled: null, 1.56 + 1.57 + /************************************************************************* 1.58 + * Server request backoff implementation fields below 1.59 + * These allow us to throttle requests if the server is getting hammered. 1.60 + **************************************************************************/ 1.61 + 1.62 + /** 1.63 + * This is an array that contains the timestamps (in unixtime) of 1.64 + * the last few backoff-triggering errors. 1.65 + */ 1.66 + _serverErrorLog: [], 1.67 + 1.68 + /** 1.69 + * If we receive this number of backoff errors within the amount of time 1.70 + * specified by _serverErrorPeriod, then we initiate backoff. 1.71 + */ 1.72 + _maxErrorsBeforeBackoff: 3, 1.73 + 1.74 + /** 1.75 + * If we receive enough consecutive errors (where "enough" is defined by 1.76 + * _maxErrorsBeforeBackoff above) within this time period, 1.77 + * we trigger the backoff behavior. 1.78 + */ 1.79 + _serverErrorPeriod: 600000, // 10 minutes in milliseconds 1.80 + 1.81 + /** 1.82 + * If we get another backoff error immediately after timeout, we increase the 1.83 + * backoff to (2 x old period) + this value. 1.84 + */ 1.85 + _serverErrorTimeoutIncrement: 600000, // 10 minutes in milliseconds 1.86 + 1.87 + /** 1.88 + * The current amount of time to wait before trying a server request 1.89 + * after receiving a backoff error. 1.90 + */ 1.91 + _serverErrorTimeout: 0, 1.92 + 1.93 + /** 1.94 + * Time (in unixtime) after which we're allowed to try requesting again. 1.95 + */ 1.96 + _nextRequestTime: 0, 1.97 + 1.98 + /** 1.99 + * The last engine we requested against (so that we can tell if the 1.100 + * user switched engines). 1.101 + */ 1.102 + _serverErrorEngine: null, 1.103 + 1.104 + /** 1.105 + * The XMLHttpRequest object. 1.106 + * @private 1.107 + */ 1.108 + _request: null, 1.109 + 1.110 + /** 1.111 + * The object implementing nsIAutoCompleteObserver that we notify when 1.112 + * we have found results 1.113 + * @private 1.114 + */ 1.115 + _listener: null, 1.116 + 1.117 + /** 1.118 + * If this is true, we'll integrate form history results with the 1.119 + * suggest results. 1.120 + */ 1.121 + _includeFormHistory: true, 1.122 + 1.123 + /** 1.124 + * True if a request for remote suggestions was sent. This is used to 1.125 + * differentiate between the "_request is null because the request has 1.126 + * already returned a result" and "_request is null because no request was 1.127 + * sent" cases. 1.128 + */ 1.129 + _sentSuggestRequest: false, 1.130 + 1.131 + /** 1.132 + * This is the callback for the suggest timeout timer. 1.133 + */ 1.134 + notify: function SAC_notify(timer) { 1.135 + // FIXME: bug 387341 1.136 + // Need to break the cycle between us and the timer. 1.137 + this._formHistoryTimer = null; 1.138 + 1.139 + // If this._listener is null, we've already sent out suggest results, so 1.140 + // nothing left to do here. 1.141 + if (!this._listener) 1.142 + return; 1.143 + 1.144 + // Otherwise, the XMLHTTPRequest for suggest results is taking too long, 1.145 + // so send out the form history results and cancel the request. 1.146 + this._listener.onSearchResult(this, this._formHistoryResult); 1.147 + this._reset(); 1.148 + }, 1.149 + 1.150 + /** 1.151 + * This determines how long (in ms) we should wait before giving up on 1.152 + * the suggestions and just showing local form history results. 1.153 + */ 1.154 + _suggestionTimeout: 500, 1.155 + 1.156 + /** 1.157 + * This is the callback for that the form history service uses to 1.158 + * send us results. 1.159 + */ 1.160 + onSearchResult: function SAC_onSearchResult(search, result) { 1.161 + this._formHistoryResult = result; 1.162 + 1.163 + if (this._request) { 1.164 + // We still have a pending request, wait a bit to give it a chance to 1.165 + // finish. 1.166 + this._formHistoryTimer = Cc["@mozilla.org/timer;1"]. 1.167 + createInstance(Ci.nsITimer); 1.168 + this._formHistoryTimer.initWithCallback(this, this._suggestionTimeout, 1.169 + Ci.nsITimer.TYPE_ONE_SHOT); 1.170 + } else if (!this._sentSuggestRequest) { 1.171 + // We didn't send a request, so just send back the form history results. 1.172 + this._listener.onSearchResult(this, this._formHistoryResult); 1.173 + this._reset(); 1.174 + } 1.175 + }, 1.176 + 1.177 + /** 1.178 + * This is the URI that the last suggest request was sent to. 1.179 + */ 1.180 + _suggestURI: null, 1.181 + 1.182 + /** 1.183 + * Autocomplete results from the form history service get stored here. 1.184 + */ 1.185 + _formHistoryResult: null, 1.186 + 1.187 + /** 1.188 + * This holds the suggest server timeout timer, if applicable. 1.189 + */ 1.190 + _formHistoryTimer: null, 1.191 + 1.192 + /** 1.193 + * Maximum number of history items displayed. This is capped at 7 1.194 + * because the primary consumer (Firefox search bar) displays 10 rows 1.195 + * by default, and so we want to leave some space for suggestions 1.196 + * to be visible. 1.197 + */ 1.198 + _historyLimit: 7, 1.199 + 1.200 + /** 1.201 + * This clears all the per-request state. 1.202 + */ 1.203 + _reset: function SAC_reset() { 1.204 + // Don't let go of our listener and form history result if the timer is 1.205 + // still pending, the timer will call _reset() when it fires. 1.206 + if (!this._formHistoryTimer) { 1.207 + this._listener = null; 1.208 + this._formHistoryResult = null; 1.209 + } 1.210 + this._request = null; 1.211 + }, 1.212 + 1.213 + /** 1.214 + * This sends an autocompletion request to the form history service, 1.215 + * which will call onSearchResults with the results of the query. 1.216 + */ 1.217 + _startHistorySearch: function SAC_SHSearch(searchString, searchParam) { 1.218 + var formHistory = 1.219 + Cc["@mozilla.org/autocomplete/search;1?name=form-history"]. 1.220 + createInstance(Ci.nsIAutoCompleteSearch); 1.221 + formHistory.startSearch(searchString, searchParam, this._formHistoryResult, this); 1.222 + }, 1.223 + 1.224 + /** 1.225 + * Makes a note of the fact that we've received a backoff-triggering 1.226 + * response, so that we can adjust the backoff behavior appropriately. 1.227 + */ 1.228 + _noteServerError: function SAC__noteServeError() { 1.229 + var currentTime = Date.now(); 1.230 + 1.231 + this._serverErrorLog.push(currentTime); 1.232 + if (this._serverErrorLog.length > this._maxErrorsBeforeBackoff) 1.233 + this._serverErrorLog.shift(); 1.234 + 1.235 + if ((this._serverErrorLog.length == this._maxErrorsBeforeBackoff) && 1.236 + ((currentTime - this._serverErrorLog[0]) < this._serverErrorPeriod)) { 1.237 + // increase timeout, and then don't request until timeout is over 1.238 + this._serverErrorTimeout = (this._serverErrorTimeout * 2) + 1.239 + this._serverErrorTimeoutIncrement; 1.240 + this._nextRequestTime = currentTime + this._serverErrorTimeout; 1.241 + } 1.242 + }, 1.243 + 1.244 + /** 1.245 + * Resets the backoff behavior; called when we get a successful response. 1.246 + */ 1.247 + _clearServerErrors: function SAC__clearServerErrors() { 1.248 + this._serverErrorLog = []; 1.249 + this._serverErrorTimeout = 0; 1.250 + this._nextRequestTime = 0; 1.251 + }, 1.252 + 1.253 + /** 1.254 + * This checks whether we should send a server request (i.e. we're not 1.255 + * in a error-triggered backoff period. 1.256 + * 1.257 + * @private 1.258 + */ 1.259 + _okToRequest: function SAC__okToRequest() { 1.260 + return Date.now() > this._nextRequestTime; 1.261 + }, 1.262 + 1.263 + /** 1.264 + * This checks to see if the new search engine is different 1.265 + * from the previous one, and if so clears any error state that might 1.266 + * have accumulated for the old engine. 1.267 + * 1.268 + * @param engine The engine that the suggestion request would be sent to. 1.269 + * @private 1.270 + */ 1.271 + _checkForEngineSwitch: function SAC__checkForEngineSwitch(engine) { 1.272 + if (engine == this._serverErrorEngine) 1.273 + return; 1.274 + 1.275 + // must've switched search providers, clear old errors 1.276 + this._serverErrorEngine = engine; 1.277 + this._clearServerErrors(); 1.278 + }, 1.279 + 1.280 + /** 1.281 + * This returns true if the status code of the HTTP response 1.282 + * represents a backoff-triggering error. 1.283 + * 1.284 + * @param status The status code from the HTTP response 1.285 + * @private 1.286 + */ 1.287 + _isBackoffError: function SAC__isBackoffError(status) { 1.288 + return ((status == HTTP_INTERNAL_SERVER_ERROR) || 1.289 + (status == HTTP_BAD_GATEWAY) || 1.290 + (status == HTTP_SERVICE_UNAVAILABLE)); 1.291 + }, 1.292 + 1.293 + /** 1.294 + * Called when the 'readyState' of the XMLHttpRequest changes. We only care 1.295 + * about state 4 (COMPLETED) - handle the response data. 1.296 + * @private 1.297 + */ 1.298 + onReadyStateChange: function() { 1.299 + // xxx use the real const here 1.300 + if (!this._request || this._request.readyState != 4) 1.301 + return; 1.302 + 1.303 + try { 1.304 + var status = this._request.status; 1.305 + } catch (e) { 1.306 + // The XML HttpRequest can throw NS_ERROR_NOT_AVAILABLE. 1.307 + return; 1.308 + } 1.309 + 1.310 + if (this._isBackoffError(status)) { 1.311 + this._noteServerError(); 1.312 + return; 1.313 + } 1.314 + 1.315 + var responseText = this._request.responseText; 1.316 + if (status != HTTP_OK || responseText == "") 1.317 + return; 1.318 + 1.319 + this._clearServerErrors(); 1.320 + 1.321 + try { 1.322 + var serverResults = JSON.parse(responseText); 1.323 + } catch(ex) { 1.324 + Components.utils.reportError("Failed to parse JSON from " + this._suggestURI.spec + ": " + ex); 1.325 + return; 1.326 + } 1.327 + 1.328 + var searchString = serverResults[0] || ""; 1.329 + var results = serverResults[1] || []; 1.330 + 1.331 + var comments = []; // "comments" column values for suggestions 1.332 + var historyResults = []; 1.333 + var historyComments = []; 1.334 + 1.335 + // If form history is enabled and has results, add them to the list. 1.336 + if (this._includeFormHistory && this._formHistoryResult && 1.337 + (this._formHistoryResult.searchResult == 1.338 + Ci.nsIAutoCompleteResult.RESULT_SUCCESS)) { 1.339 + var maxHistoryItems = Math.min(this._formHistoryResult.matchCount, this._historyLimit); 1.340 + for (var i = 0; i < maxHistoryItems; ++i) { 1.341 + var term = this._formHistoryResult.getValueAt(i); 1.342 + 1.343 + // we don't want things to appear in both history and suggestions 1.344 + var dupIndex = results.indexOf(term); 1.345 + if (dupIndex != -1) 1.346 + results.splice(dupIndex, 1); 1.347 + 1.348 + historyResults.push(term); 1.349 + historyComments.push(""); 1.350 + } 1.351 + } 1.352 + 1.353 + // fill out the comment column for the suggestions 1.354 + for (var i = 0; i < results.length; ++i) 1.355 + comments.push(""); 1.356 + 1.357 + // if we have any suggestions, put a label at the top 1.358 + if (comments.length > 0) 1.359 + comments[0] = this._suggestionLabel; 1.360 + 1.361 + // now put the history results above the suggestions 1.362 + var finalResults = historyResults.concat(results); 1.363 + var finalComments = historyComments.concat(comments); 1.364 + 1.365 + // Notify the FE of our new results 1.366 + this.onResultsReady(searchString, finalResults, finalComments, 1.367 + this._formHistoryResult); 1.368 + 1.369 + // Reset our state for next time. 1.370 + this._reset(); 1.371 + }, 1.372 + 1.373 + /** 1.374 + * Notifies the front end of new results. 1.375 + * @param searchString the user's query string 1.376 + * @param results an array of results to the search 1.377 + * @param comments an array of metadata corresponding to the results 1.378 + * @private 1.379 + */ 1.380 + onResultsReady: function(searchString, results, comments, 1.381 + formHistoryResult) { 1.382 + if (this._listener) { 1.383 + var result = new FormAutoCompleteResult( 1.384 + searchString, 1.385 + Ci.nsIAutoCompleteResult.RESULT_SUCCESS, 1.386 + 0, 1.387 + "", 1.388 + results, 1.389 + results, 1.390 + comments, 1.391 + formHistoryResult); 1.392 + 1.393 + this._listener.onSearchResult(this, result); 1.394 + 1.395 + // Null out listener to make sure we don't notify it twice, in case our 1.396 + // timer callback still hasn't run. 1.397 + this._listener = null; 1.398 + } 1.399 + }, 1.400 + 1.401 + /** 1.402 + * Initiates the search result gathering process. Part of 1.403 + * nsIAutoCompleteSearch implementation. 1.404 + * 1.405 + * @param searchString the user's query string 1.406 + * @param searchParam unused, "an extra parameter"; even though 1.407 + * this parameter and the next are unused, pass 1.408 + * them through in case the form history 1.409 + * service wants them 1.410 + * @param previousResult unused, a client-cached store of the previous 1.411 + * generated resultset for faster searching. 1.412 + * @param listener object implementing nsIAutoCompleteObserver which 1.413 + * we notify when results are ready. 1.414 + */ 1.415 + startSearch: function(searchString, searchParam, previousResult, listener) { 1.416 + // Don't reuse a previous form history result when it no longer applies. 1.417 + if (!previousResult) 1.418 + this._formHistoryResult = null; 1.419 + 1.420 + var formHistorySearchParam = searchParam.split("|")[0]; 1.421 + 1.422 + // Receive the information about the privacy mode of the window to which 1.423 + // this search box belongs. The front-end's search.xml bindings passes this 1.424 + // information in the searchParam parameter. The alternative would have 1.425 + // been to modify nsIAutoCompleteSearch to add an argument to startSearch 1.426 + // and patch all of autocomplete to be aware of this, but the searchParam 1.427 + // argument is already an opaque argument, so this solution is hopefully 1.428 + // less hackish (although still gross.) 1.429 + var privacyMode = (searchParam.split("|")[1] == "private"); 1.430 + 1.431 + // Start search immediately if possible, otherwise once the search 1.432 + // service is initialized 1.433 + if (Services.search.isInitialized) { 1.434 + this._triggerSearch(searchString, formHistorySearchParam, listener, privacyMode); 1.435 + return; 1.436 + } 1.437 + 1.438 + Services.search.init((function startSearch_cb(aResult) { 1.439 + if (!Components.isSuccessCode(aResult)) { 1.440 + Cu.reportError("Could not initialize search service, bailing out: " + aResult); 1.441 + return; 1.442 + } 1.443 + this._triggerSearch(searchString, formHistorySearchParam, listener, privacyMode); 1.444 + }).bind(this)); 1.445 + }, 1.446 + 1.447 + /** 1.448 + * Actual implementation of search. 1.449 + */ 1.450 + _triggerSearch: function(searchString, searchParam, listener, privacyMode) { 1.451 + // If there's an existing request, stop it. There is no smart filtering 1.452 + // here as there is when looking through history/form data because the 1.453 + // result set returned by the server is different for every typed value - 1.454 + // "ocean breathes" does not return a subset of the results returned for 1.455 + // "ocean", for example. This does nothing if there is no current request. 1.456 + this.stopSearch(); 1.457 + 1.458 + this._listener = listener; 1.459 + 1.460 + var engine = Services.search.currentEngine; 1.461 + 1.462 + this._checkForEngineSwitch(engine); 1.463 + 1.464 + if (!searchString || 1.465 + !this._suggestEnabled || 1.466 + !engine.supportsResponseType(SEARCH_RESPONSE_SUGGESTION_JSON) || 1.467 + !this._okToRequest()) { 1.468 + // We have an empty search string (user pressed down arrow to see 1.469 + // history), or search suggestions are disabled, or the current engine 1.470 + // has no suggest functionality, or we're in backoff mode; so just use 1.471 + // local history. 1.472 + this._sentSuggestRequest = false; 1.473 + this._startHistorySearch(searchString, searchParam); 1.474 + return; 1.475 + } 1.476 + 1.477 + // Actually do the search 1.478 + this._request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. 1.479 + createInstance(Ci.nsIXMLHttpRequest); 1.480 + var submission = engine.getSubmission(searchString, 1.481 + SEARCH_RESPONSE_SUGGESTION_JSON); 1.482 + this._suggestURI = submission.uri; 1.483 + var method = (submission.postData ? "POST" : "GET"); 1.484 + this._request.open(method, this._suggestURI.spec, true); 1.485 + this._request.channel.notificationCallbacks = new AuthPromptOverride(); 1.486 + if (this._request.channel instanceof Ci.nsIPrivateBrowsingChannel) { 1.487 + this._request.channel.setPrivate(privacyMode); 1.488 + } 1.489 + 1.490 + var self = this; 1.491 + function onReadyStateChange() { 1.492 + self.onReadyStateChange(); 1.493 + } 1.494 + this._request.onreadystatechange = onReadyStateChange; 1.495 + this._request.send(submission.postData); 1.496 + 1.497 + if (this._includeFormHistory) { 1.498 + this._sentSuggestRequest = true; 1.499 + this._startHistorySearch(searchString, searchParam); 1.500 + } 1.501 + }, 1.502 + 1.503 + /** 1.504 + * Ends the search result gathering process. Part of nsIAutoCompleteSearch 1.505 + * implementation. 1.506 + */ 1.507 + stopSearch: function() { 1.508 + if (this._request) { 1.509 + this._request.abort(); 1.510 + this._reset(); 1.511 + } 1.512 + }, 1.513 + 1.514 + /** 1.515 + * nsIObserver 1.516 + */ 1.517 + observe: function SAC_observe(aSubject, aTopic, aData) { 1.518 + switch (aTopic) { 1.519 + case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID: 1.520 + this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF); 1.521 + break; 1.522 + case XPCOM_SHUTDOWN_TOPIC: 1.523 + this._removeObservers(); 1.524 + break; 1.525 + } 1.526 + }, 1.527 + 1.528 + _addObservers: function SAC_addObservers() { 1.529 + Services.prefs.addObserver(BROWSER_SUGGEST_PREF, this, false); 1.530 + 1.531 + Services.obs.addObserver(this, XPCOM_SHUTDOWN_TOPIC, false); 1.532 + }, 1.533 + 1.534 + _removeObservers: function SAC_removeObservers() { 1.535 + Services.prefs.removeObserver(BROWSER_SUGGEST_PREF, this); 1.536 + 1.537 + Services.obs.removeObserver(this, XPCOM_SHUTDOWN_TOPIC); 1.538 + }, 1.539 + 1.540 + // nsISupports 1.541 + QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteSearch, 1.542 + Ci.nsIAutoCompleteObserver]) 1.543 +}; 1.544 + 1.545 +function AuthPromptOverride() { 1.546 +} 1.547 +AuthPromptOverride.prototype = { 1.548 + // nsIAuthPromptProvider 1.549 + getAuthPrompt: function (reason, iid) { 1.550 + // Return a no-op nsIAuthPrompt2 implementation. 1.551 + return { 1.552 + promptAuth: function () { 1.553 + throw Cr.NS_ERROR_NOT_IMPLEMENTED; 1.554 + }, 1.555 + asyncPromptAuth: function () { 1.556 + throw Cr.NS_ERROR_NOT_IMPLEMENTED; 1.557 + } 1.558 + }; 1.559 + }, 1.560 + 1.561 + // nsIInterfaceRequestor 1.562 + getInterface: function SSLL_getInterface(iid) { 1.563 + return this.QueryInterface(iid); 1.564 + }, 1.565 + 1.566 + // nsISupports 1.567 + QueryInterface: XPCOMUtils.generateQI([Ci.nsIAuthPromptProvider, 1.568 + Ci.nsIInterfaceRequestor]) 1.569 +}; 1.570 +/** 1.571 + * SearchSuggestAutoComplete is a service implementation that handles suggest 1.572 + * results specific to web searches. 1.573 + * @constructor 1.574 + */ 1.575 +function SearchSuggestAutoComplete() { 1.576 + // This calls _init() in the parent class (SuggestAutoComplete) via the 1.577 + // prototype, below. 1.578 + this._init(); 1.579 +} 1.580 +SearchSuggestAutoComplete.prototype = { 1.581 + classID: Components.ID("{aa892eb4-ffbf-477d-9f9a-06c995ae9f27}"), 1.582 + __proto__: SuggestAutoComplete.prototype, 1.583 + serviceURL: "" 1.584 +}; 1.585 + 1.586 +var component = [SearchSuggestAutoComplete]; 1.587 +this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component);