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/. */ 4"use strict"; 5 6var EXPORTED_SYMBOLS = ["ContentSearchParent", "ContentSearch"]; 7 8const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 9const { XPCOMUtils } = ChromeUtils.import( 10 "resource://gre/modules/XPCOMUtils.jsm" 11); 12 13XPCOMUtils.defineLazyGlobalGetters(this, ["XMLHttpRequest"]); 14 15ChromeUtils.defineModuleGetter( 16 this, 17 "FormHistory", 18 "resource://gre/modules/FormHistory.jsm" 19); 20ChromeUtils.defineModuleGetter( 21 this, 22 "PrivateBrowsingUtils", 23 "resource://gre/modules/PrivateBrowsingUtils.jsm" 24); 25ChromeUtils.defineModuleGetter( 26 this, 27 "SearchSuggestionController", 28 "resource://gre/modules/SearchSuggestionController.jsm" 29); 30 31const MAX_LOCAL_SUGGESTIONS = 3; 32const MAX_SUGGESTIONS = 6; 33 34// Set of all ContentSearch actors, used to broadcast messages to all of them. 35let gContentSearchActors = new Set(); 36 37/** 38 * Inbound messages have the following types: 39 * 40 * AddFormHistoryEntry 41 * Adds an entry to the search form history. 42 * data: the entry, a string 43 * GetSuggestions 44 * Retrieves an array of search suggestions given a search string. 45 * data: { engineName, searchString } 46 * GetState 47 * Retrieves the current search engine state. 48 * data: null 49 * GetStrings 50 * Retrieves localized search UI strings. 51 * data: null 52 * ManageEngines 53 * Opens the search engine management window. 54 * data: null 55 * RemoveFormHistoryEntry 56 * Removes an entry from the search form history. 57 * data: the entry, a string 58 * Search 59 * Performs a search. 60 * Any GetSuggestions messages in the queue from the same target will be 61 * cancelled. 62 * data: { engineName, searchString, healthReportKey, searchPurpose } 63 * SetCurrentEngine 64 * Sets the current engine. 65 * data: the name of the engine 66 * SpeculativeConnect 67 * Speculatively connects to an engine. 68 * data: the name of the engine 69 * 70 * Outbound messages have the following types: 71 * 72 * CurrentEngine 73 * Broadcast when the current engine changes. 74 * data: see _currentEngineObj 75 * CurrentState 76 * Broadcast when the current search state changes. 77 * data: see currentStateObj 78 * State 79 * Sent in reply to GetState. 80 * data: see currentStateObj 81 * Strings 82 * Sent in reply to GetStrings 83 * data: Object containing string names and values for the current locale. 84 * Suggestions 85 * Sent in reply to GetSuggestions. 86 * data: see _onMessageGetSuggestions 87 * SuggestionsCancelled 88 * Sent in reply to GetSuggestions when pending GetSuggestions events are 89 * cancelled. 90 * data: null 91 */ 92 93let ContentSearch = { 94 initialized: false, 95 96 // Inbound events are queued and processed in FIFO order instead of handling 97 // them immediately, which would result in non-FIFO responses due to the 98 // asynchrononicity added by converting image data URIs to ArrayBuffers. 99 _eventQueue: [], 100 _currentEventPromise: null, 101 102 // This is used to handle search suggestions. It maps xul:browsers to objects 103 // { controller, previousFormHistoryResult }. See _onMessageGetSuggestions. 104 _suggestionMap: new WeakMap(), 105 106 // Resolved when we finish shutting down. 107 _destroyedPromise: null, 108 109 // The current controller and browser in _onMessageGetSuggestions. Allows 110 // fetch cancellation from _cancelSuggestions. 111 _currentSuggestion: null, 112 113 init() { 114 if (!this.initialized) { 115 Services.obs.addObserver(this, "browser-search-engine-modified"); 116 Services.obs.addObserver(this, "browser-search-service"); 117 Services.obs.addObserver(this, "shutdown-leaks-before-check"); 118 Services.prefs.addObserver("browser.search.hiddenOneOffs", this); 119 this._stringBundle = Services.strings.createBundle( 120 "chrome://global/locale/autocomplete.properties" 121 ); 122 123 this.initialized = true; 124 } 125 }, 126 127 get searchSuggestionUIStrings() { 128 if (this._searchSuggestionUIStrings) { 129 return this._searchSuggestionUIStrings; 130 } 131 this._searchSuggestionUIStrings = {}; 132 let searchBundle = Services.strings.createBundle( 133 "chrome://browser/locale/search.properties" 134 ); 135 let stringNames = [ 136 "searchHeader", 137 "searchForSomethingWith2", 138 "searchWithHeader", 139 "searchSettings", 140 ]; 141 142 for (let name of stringNames) { 143 this._searchSuggestionUIStrings[name] = searchBundle.GetStringFromName( 144 name 145 ); 146 } 147 return this._searchSuggestionUIStrings; 148 }, 149 150 destroy() { 151 if (!this.initialized) { 152 return new Promise(); 153 } 154 155 if (this._destroyedPromise) { 156 return this._destroyedPromise; 157 } 158 159 Services.obs.removeObserver(this, "browser-search-engine-modified"); 160 Services.obs.removeObserver(this, "browser-search-service"); 161 Services.obs.removeObserver(this, "shutdown-leaks-before-check"); 162 163 this._eventQueue.length = 0; 164 this._destroyedPromise = Promise.resolve(this._currentEventPromise); 165 return this._destroyedPromise; 166 }, 167 168 observe(subj, topic, data) { 169 switch (topic) { 170 case "browser-search-service": 171 if (data != "init-complete") { 172 break; 173 } 174 // fall through 175 case "nsPref:changed": 176 case "browser-search-engine-modified": 177 this._eventQueue.push({ 178 type: "Observe", 179 data, 180 }); 181 this._processEventQueue(); 182 break; 183 case "shutdown-leaks-before-check": 184 subj.wrappedJSObject.client.addBlocker( 185 "ContentSearch: Wait until the service is destroyed", 186 () => this.destroy() 187 ); 188 break; 189 } 190 }, 191 192 removeFormHistoryEntry(browser, entry) { 193 let browserData = this._suggestionDataForBrowser(browser); 194 if (browserData && browserData.previousFormHistoryResult) { 195 let { previousFormHistoryResult } = browserData; 196 for (let i = 0; i < previousFormHistoryResult.matchCount; i++) { 197 if (previousFormHistoryResult.getValueAt(i) === entry) { 198 previousFormHistoryResult.removeValueAt(i); 199 break; 200 } 201 } 202 } 203 }, 204 205 performSearch(browser, data) { 206 this._ensureDataHasProperties(data, [ 207 "engineName", 208 "searchString", 209 "healthReportKey", 210 "searchPurpose", 211 ]); 212 let engine = Services.search.getEngineByName(data.engineName); 213 let submission = engine.getSubmission( 214 data.searchString, 215 "", 216 data.searchPurpose 217 ); 218 let win = browser.ownerGlobal; 219 if (!win) { 220 // The browser may have been closed between the time its content sent the 221 // message and the time we handle it. 222 return; 223 } 224 let where = win.whereToOpenLink(data.originalEvent); 225 226 // There is a chance that by the time we receive the search message, the user 227 // has switched away from the tab that triggered the search. If, based on the 228 // event, we need to load the search in the same tab that triggered it (i.e. 229 // where === "current"), openUILinkIn will not work because that tab is no 230 // longer the current one. For this case we manually load the URI. 231 if (where === "current") { 232 // Since we're going to load the search in the same browser, blur the search 233 // UI to prevent further interaction before we start loading. 234 this._reply(browser, "Blur"); 235 browser.loadURI(submission.uri.spec, { 236 postData: submission.postData, 237 triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( 238 { 239 userContextId: win.gBrowser.selectedBrowser.getAttribute( 240 "userContextId" 241 ), 242 } 243 ), 244 }); 245 } else { 246 let params = { 247 postData: submission.postData, 248 inBackground: Services.prefs.getBoolPref( 249 "browser.tabs.loadInBackground" 250 ), 251 }; 252 win.openTrustedLinkIn(submission.uri.spec, where, params); 253 } 254 win.BrowserSearch.recordSearchInTelemetry(engine, data.healthReportKey, { 255 selection: data.selection, 256 }); 257 }, 258 259 async getSuggestions(engineName, searchString, browser) { 260 let engine = Services.search.getEngineByName(engineName); 261 if (!engine) { 262 throw new Error("Unknown engine name: " + engineName); 263 } 264 265 let browserData = this._suggestionDataForBrowser(browser, true); 266 let { controller } = browserData; 267 let ok = SearchSuggestionController.engineOffersSuggestions(engine); 268 controller.maxLocalResults = ok ? MAX_LOCAL_SUGGESTIONS : MAX_SUGGESTIONS; 269 controller.maxRemoteResults = ok ? MAX_SUGGESTIONS : 0; 270 let priv = PrivateBrowsingUtils.isBrowserPrivate(browser); 271 // fetch() rejects its promise if there's a pending request, but since we 272 // process our event queue serially, there's never a pending request. 273 this._currentSuggestion = { controller, browser }; 274 let suggestions = await controller.fetch(searchString, priv, engine); 275 276 // Simplify results since we do not support rich results in this component. 277 suggestions.local = suggestions.local.map(e => e.value); 278 // We shouldn't show tail suggestions in their full-text form. 279 let nonTailEntries = suggestions.remote.filter( 280 e => !e.matchPrefix && !e.tail 281 ); 282 suggestions.remote = nonTailEntries.map(e => e.value); 283 284 this._currentSuggestion = null; 285 286 // suggestions will be null if the request was cancelled 287 let result = {}; 288 if (!suggestions) { 289 return result; 290 } 291 292 // Keep the form history result so RemoveFormHistoryEntry can remove entries 293 // from it. Keeping only one result isn't foolproof because the client may 294 // try to remove an entry from one set of suggestions after it has requested 295 // more but before it's received them. In that case, the entry may not 296 // appear in the new suggestions. But that should happen rarely. 297 browserData.previousFormHistoryResult = suggestions.formHistoryResult; 298 result = { 299 engineName, 300 term: suggestions.term, 301 local: suggestions.local, 302 remote: suggestions.remote, 303 }; 304 return result; 305 }, 306 307 async addFormHistoryEntry(browser, entry = "") { 308 let isPrivate = false; 309 try { 310 // isBrowserPrivate assumes that the passed-in browser has all the normal 311 // properties, which won't be true if the browser has been destroyed. 312 // That may be the case here due to the asynchronous nature of messaging. 313 isPrivate = PrivateBrowsingUtils.isBrowserPrivate(browser); 314 } catch (err) { 315 return false; 316 } 317 if (isPrivate || entry === "") { 318 return false; 319 } 320 let browserData = this._suggestionDataForBrowser(browser, true); 321 FormHistory.update( 322 { 323 op: "bump", 324 fieldname: browserData.controller.formHistoryParam, 325 value: entry, 326 }, 327 { 328 handleCompletion: () => {}, 329 handleError: err => { 330 Cu.reportError("Error adding form history entry: " + err); 331 }, 332 } 333 ); 334 return true; 335 }, 336 337 async currentStateObj(window) { 338 let state = { 339 engines: [], 340 currentEngine: await this._currentEngineObj(false), 341 currentPrivateEngine: await this._currentEngineObj(true), 342 }; 343 344 let pref = Services.prefs.getStringPref("browser.search.hiddenOneOffs"); 345 let hiddenList = pref ? pref.split(",") : []; 346 for (let engine of await Services.search.getVisibleEngines()) { 347 let uri = engine.getIconURLBySize(16, 16); 348 let iconData = await this._maybeConvertURIToArrayBuffer(uri); 349 350 state.engines.push({ 351 name: engine.name, 352 iconData, 353 hidden: hiddenList.includes(engine.name), 354 isAppProvided: engine.isAppProvided, 355 }); 356 } 357 358 if (window) { 359 state.isPrivateWindow = PrivateBrowsingUtils.isContentWindowPrivate( 360 window 361 ); 362 } 363 364 return state; 365 }, 366 367 _processEventQueue() { 368 if (this._currentEventPromise || !this._eventQueue.length) { 369 return; 370 } 371 372 let event = this._eventQueue.shift(); 373 374 this._currentEventPromise = (async () => { 375 try { 376 await this["_on" + event.type](event); 377 } catch (err) { 378 Cu.reportError(err); 379 } finally { 380 this._currentEventPromise = null; 381 382 this._processEventQueue(); 383 } 384 })(); 385 }, 386 387 _cancelSuggestions(browser) { 388 let cancelled = false; 389 // cancel active suggestion request 390 if ( 391 this._currentSuggestion && 392 this._currentSuggestion.browser === browser 393 ) { 394 this._currentSuggestion.controller.stop(); 395 cancelled = true; 396 } 397 // cancel queued suggestion requests 398 for (let i = 0; i < this._eventQueue.length; i++) { 399 let m = this._eventQueue[i]; 400 if (browser === m.browser && m.name === "GetSuggestions") { 401 this._eventQueue.splice(i, 1); 402 cancelled = true; 403 i--; 404 } 405 } 406 if (cancelled) { 407 this._reply(browser, "SuggestionsCancelled"); 408 } 409 }, 410 411 async _onMessage(eventItem) { 412 let methodName = "_onMessage" + eventItem.name; 413 if (methodName in this) { 414 await this._initService(); 415 await this[methodName](eventItem.browser, eventItem.data); 416 eventItem.browser.removeEventListener("SwapDocShells", eventItem, true); 417 } 418 }, 419 420 _onMessageGetState(browser, data) { 421 return this.currentStateObj(browser.ownerGlobal).then(state => { 422 this._reply(browser, "State", state); 423 }); 424 }, 425 426 _onMessageGetEngine(browser, data) { 427 return this.currentStateObj(browser.ownerGlobal).then(state => { 428 this._reply(browser, "Engine", { 429 isPrivateWindow: state.isPrivateWindow, 430 engine: state.isPrivateWindow 431 ? state.currentPrivateEngine 432 : state.currentEngine, 433 }); 434 }); 435 }, 436 437 _onMessageGetStrings(browser, data) { 438 this._reply(browser, "Strings", this.searchSuggestionUIStrings); 439 }, 440 441 _onMessageSearch(browser, data) { 442 this.performSearch(browser, data); 443 }, 444 445 _onMessageSetCurrentEngine(browser, data) { 446 Services.search.defaultEngine = Services.search.getEngineByName(data); 447 }, 448 449 _onMessageManageEngines(browser) { 450 browser.ownerGlobal.openPreferences("paneSearch"); 451 }, 452 453 async _onMessageGetSuggestions(browser, data) { 454 this._ensureDataHasProperties(data, ["engineName", "searchString"]); 455 let { engineName, searchString } = data; 456 let suggestions = await this.getSuggestions( 457 engineName, 458 searchString, 459 browser 460 ); 461 462 this._reply(browser, "Suggestions", { 463 engineName: data.engineName, 464 searchString: suggestions.term, 465 formHistory: suggestions.local, 466 remote: suggestions.remote, 467 }); 468 }, 469 470 async _onMessageAddFormHistoryEntry(browser, entry) { 471 await this.addFormHistoryEntry(browser, entry); 472 }, 473 474 _onMessageRemoveFormHistoryEntry(browser, entry) { 475 this.removeFormHistoryEntry(browser, entry); 476 }, 477 478 _onMessageSpeculativeConnect(browser, engineName) { 479 let engine = Services.search.getEngineByName(engineName); 480 if (!engine) { 481 throw new Error("Unknown engine name: " + engineName); 482 } 483 if (browser.contentWindow) { 484 engine.speculativeConnect({ 485 window: browser.contentWindow, 486 originAttributes: browser.contentPrincipal.originAttributes, 487 }); 488 } 489 }, 490 491 async _onObserve(eventItem) { 492 if (eventItem.data === "engine-default") { 493 let engine = await this._currentEngineObj(false); 494 this._broadcast("CurrentEngine", engine); 495 } else if (eventItem.data === "engine-default-private") { 496 let engine = await this._currentEngineObj(true); 497 this._broadcast("CurrentPrivateEngine", engine); 498 } else { 499 let state = await this.currentStateObj(); 500 this._broadcast("CurrentState", state); 501 } 502 }, 503 504 _suggestionDataForBrowser(browser, create = false) { 505 let data = this._suggestionMap.get(browser); 506 if (!data && create) { 507 // Since one SearchSuggestionController instance is meant to be used per 508 // autocomplete widget, this means that we assume each xul:browser has at 509 // most one such widget. 510 data = { 511 controller: new SearchSuggestionController(), 512 }; 513 this._suggestionMap.set(browser, data); 514 } 515 return data; 516 }, 517 518 _reply(browser, type, data) { 519 browser.sendMessageToActor(type, data, "ContentSearch"); 520 }, 521 522 _broadcast(type, data) { 523 for (let actor of gContentSearchActors) { 524 actor.sendAsyncMessage(type, data); 525 } 526 }, 527 528 async _currentEngineObj(usePrivate) { 529 let engine = 530 Services.search[usePrivate ? "defaultPrivateEngine" : "defaultEngine"]; 531 let favicon = engine.getIconURLBySize(16, 16); 532 let placeholder = this._stringBundle.formatStringFromName( 533 "searchWithEngine", 534 [engine.name] 535 ); 536 let obj = { 537 name: engine.name, 538 placeholder, 539 iconData: await this._maybeConvertURIToArrayBuffer(favicon), 540 isAppProvided: engine.isAppProvided, 541 }; 542 return obj; 543 }, 544 545 _maybeConvertURIToArrayBuffer(uri) { 546 if (!uri) { 547 return Promise.resolve(null); 548 } 549 550 // The uri received here can be of two types 551 // 1 - moz-extension://[uuid]/path/to/icon.ico 552 // 2 - data:image/x-icon;base64,VERY-LONG-STRING 553 // 554 // If the URI is not a data: URI, there's no point in converting 555 // it to an arraybuffer (which is used to optimize passing the data 556 // accross processes): we can just pass the original URI, which is cheaper. 557 if (!uri.startsWith("data:")) { 558 return Promise.resolve(uri); 559 } 560 561 return new Promise(resolve => { 562 let xhr = new XMLHttpRequest(); 563 xhr.open("GET", uri, true); 564 xhr.responseType = "arraybuffer"; 565 xhr.onload = () => { 566 resolve(xhr.response); 567 }; 568 xhr.onerror = xhr.onabort = xhr.ontimeout = () => { 569 resolve(null); 570 }; 571 try { 572 // This throws if the URI is erroneously encoded. 573 xhr.send(); 574 } catch (err) { 575 resolve(null); 576 } 577 }); 578 }, 579 580 _ensureDataHasProperties(data, requiredProperties) { 581 for (let prop of requiredProperties) { 582 if (!(prop in data)) { 583 throw new Error("Message data missing required property: " + prop); 584 } 585 } 586 }, 587 588 _initService() { 589 if (!this._initServicePromise) { 590 this._initServicePromise = Services.search.init(); 591 } 592 return this._initServicePromise; 593 }, 594}; 595 596class ContentSearchParent extends JSWindowActorParent { 597 constructor() { 598 super(); 599 ContentSearch.init(); 600 gContentSearchActors.add(this); 601 } 602 603 didDestroy() { 604 gContentSearchActors.delete(this); 605 } 606 607 receiveMessage(msg) { 608 // Add a temporary event handler that exists only while the message is in 609 // the event queue. If the message's source docshell changes browsers in 610 // the meantime, then we need to update the browser. event.detail will be 611 // the docshell's new parent <xul:browser> element. 612 let browser = this.browsingContext.top.embedderElement; 613 let eventItem = { 614 type: "Message", 615 name: msg.name, 616 data: msg.data, 617 browser, 618 handleEvent: event => { 619 let browserData = ContentSearch._suggestionMap.get(eventItem.browser); 620 if (browserData) { 621 ContentSearch._suggestionMap.delete(eventItem.browser); 622 ContentSearch._suggestionMap.set(event.detail, browserData); 623 } 624 browser.removeEventListener("SwapDocShells", eventItem, true); 625 eventItem.browser = event.detail; 626 eventItem.browser.addEventListener("SwapDocShells", eventItem, true); 627 }, 628 }; 629 browser.addEventListener("SwapDocShells", eventItem, true); 630 631 // Search requests cause cancellation of all Suggestion requests from the 632 // same browser. 633 if (msg.name === "Search") { 634 ContentSearch._cancelSuggestions(); 635 } 636 637 ContentSearch._eventQueue.push(eventItem); 638 ContentSearch._processEventQueue(); 639 } 640} 641