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/. */
6 var EXPORTED_SYMBOLS = ["ContentSearchParent", "ContentSearch"];
8 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
9 const { XPCOMUtils } = ChromeUtils.import(
10 "resource://gre/modules/XPCOMUtils.jsm"
13 XPCOMUtils.defineLazyGlobalGetters(this, ["XMLHttpRequest"]);
15 XPCOMUtils.defineLazyModuleGetters(this, {
16 BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.jsm",
17 FormHistory: "resource://gre/modules/FormHistory.jsm",
18 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
19 SearchSuggestionController:
20 "resource://gre/modules/SearchSuggestionController.jsm",
23 const MAX_LOCAL_SUGGESTIONS = 3;
24 const MAX_SUGGESTIONS = 6;
25 const SEARCH_ENGINE_PLACEHOLDER_ICON =
26 "chrome://browser/skin/search-engine-placeholder.png";
28 // Set of all ContentSearch actors, used to broadcast messages to all of them.
29 let gContentSearchActors = new Set();
32 * Inbound messages have the following types:
35 * Adds an entry to the search form history.
36 * data: the entry, a string
38 * Retrieves an array of search suggestions given a search string.
39 * data: { engineName, searchString }
41 * Retrieves the current search engine state.
44 * Retrieves localized search UI strings.
47 * Opens the search engine management window.
49 * RemoveFormHistoryEntry
50 * Removes an entry from the search form history.
51 * data: the entry, a string
54 * Any GetSuggestions messages in the queue from the same target will be
56 * data: { engineName, searchString, healthReportKey, searchPurpose }
58 * Sets the current engine.
59 * data: the name of the engine
61 * Speculatively connects to an engine.
62 * data: the name of the engine
64 * Outbound messages have the following types:
67 * Broadcast when the current engine changes.
68 * data: see _currentEngineObj
70 * Broadcast when the current search state changes.
71 * data: see currentStateObj
73 * Sent in reply to GetState.
74 * data: see currentStateObj
76 * Sent in reply to GetStrings
77 * data: Object containing string names and values for the current locale.
79 * Sent in reply to GetSuggestions.
80 * data: see _onMessageGetSuggestions
81 * SuggestionsCancelled
82 * Sent in reply to GetSuggestions when pending GetSuggestions events are
90 // Inbound events are queued and processed in FIFO order instead of handling
91 // them immediately, which would result in non-FIFO responses due to the
92 // asynchrononicity added by converting image data URIs to ArrayBuffers.
94 _currentEventPromise: null,
96 // This is used to handle search suggestions. It maps xul:browsers to objects
97 // { controller, previousFormHistoryResult }. See _onMessageGetSuggestions.
98 _suggestionMap: new WeakMap(),
100 // Resolved when we finish shutting down.
101 _destroyedPromise: null,
103 // The current controller and browser in _onMessageGetSuggestions. Allows
104 // fetch cancellation from _cancelSuggestions.
105 _currentSuggestion: null,
108 if (!this.initialized) {
109 Services.obs.addObserver(this, "browser-search-engine-modified");
110 Services.obs.addObserver(this, "browser-search-service");
111 Services.obs.addObserver(this, "shutdown-leaks-before-check");
112 Services.prefs.addObserver("browser.search.hiddenOneOffs", this);
114 this.initialized = true;
118 get searchSuggestionUIStrings() {
119 if (this._searchSuggestionUIStrings) {
120 return this._searchSuggestionUIStrings;
122 this._searchSuggestionUIStrings = {};
123 let searchBundle = Services.strings.createBundle(
124 "chrome://browser/locale/search.properties"
128 "searchForSomethingWith2",
133 for (let name of stringNames) {
134 this._searchSuggestionUIStrings[name] = searchBundle.GetStringFromName(
138 return this._searchSuggestionUIStrings;
142 if (!this.initialized) {
143 return new Promise();
146 if (this._destroyedPromise) {
147 return this._destroyedPromise;
150 Services.obs.removeObserver(this, "browser-search-engine-modified");
151 Services.obs.removeObserver(this, "browser-search-service");
152 Services.obs.removeObserver(this, "shutdown-leaks-before-check");
154 this._eventQueue.length = 0;
155 this._destroyedPromise = Promise.resolve(this._currentEventPromise);
156 return this._destroyedPromise;
159 observe(subj, topic, data) {
161 case "browser-search-service":
162 if (data != "init-complete") {
166 case "nsPref:changed":
167 case "browser-search-engine-modified":
168 this._eventQueue.push({
172 this._processEventQueue();
174 case "shutdown-leaks-before-check":
175 subj.wrappedJSObject.client.addBlocker(
176 "ContentSearch: Wait until the service is destroyed",
183 removeFormHistoryEntry(browser, entry) {
184 let browserData = this._suggestionDataForBrowser(browser);
185 if (browserData && browserData.previousFormHistoryResult) {
186 let { previousFormHistoryResult } = browserData;
187 for (let i = 0; i < previousFormHistoryResult.matchCount; i++) {
188 if (previousFormHistoryResult.getValueAt(i) === entry) {
189 previousFormHistoryResult.removeValueAt(i);
196 performSearch(browser, data) {
197 this._ensureDataHasProperties(data, [
203 let engine = Services.search.getEngineByName(data.engineName);
204 let submission = engine.getSubmission(
209 let win = browser.ownerGlobal;
211 // The browser may have been closed between the time its content sent the
212 // message and the time we handle it.
215 let where = win.whereToOpenLink(data.originalEvent);
217 // There is a chance that by the time we receive the search message, the user
218 // has switched away from the tab that triggered the search. If, based on the
219 // event, we need to load the search in the same tab that triggered it (i.e.
220 // where === "current"), openUILinkIn will not work because that tab is no
221 // longer the current one. For this case we manually load the URI.
222 if (where === "current") {
223 // Since we're going to load the search in the same browser, blur the search
224 // UI to prevent further interaction before we start loading.
225 this._reply(browser, "Blur");
226 browser.loadURI(submission.uri.spec, {
227 postData: submission.postData,
228 triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
230 userContextId: win.gBrowser.selectedBrowser.getAttribute(
238 postData: submission.postData,
239 inBackground: Services.prefs.getBoolPref(
240 "browser.tabs.loadInBackground"
243 win.openTrustedLinkIn(submission.uri.spec, where, params);
245 BrowserSearchTelemetry.recordSearch(browser, engine, data.healthReportKey, {
246 selection: data.selection,
251 async getSuggestions(engineName, searchString, browser) {
252 let engine = Services.search.getEngineByName(engineName);
254 throw new Error("Unknown engine name: " + engineName);
257 let browserData = this._suggestionDataForBrowser(browser, true);
258 let { controller } = browserData;
259 let ok = SearchSuggestionController.engineOffersSuggestions(engine);
260 controller.maxLocalResults = ok ? MAX_LOCAL_SUGGESTIONS : MAX_SUGGESTIONS;
261 controller.maxRemoteResults = ok ? MAX_SUGGESTIONS : 0;
262 let priv = PrivateBrowsingUtils.isBrowserPrivate(browser);
263 // fetch() rejects its promise if there's a pending request, but since we
264 // process our event queue serially, there's never a pending request.
265 this._currentSuggestion = { controller, browser };
266 let suggestions = await controller.fetch(searchString, priv, engine);
268 // Simplify results since we do not support rich results in this component.
269 suggestions.local = suggestions.local.map(e => e.value);
270 // We shouldn't show tail suggestions in their full-text form.
271 let nonTailEntries = suggestions.remote.filter(
272 e => !e.matchPrefix && !e.tail
274 suggestions.remote = nonTailEntries.map(e => e.value);
276 this._currentSuggestion = null;
278 // suggestions will be null if the request was cancelled
284 // Keep the form history result so RemoveFormHistoryEntry can remove entries
285 // from it. Keeping only one result isn't foolproof because the client may
286 // try to remove an entry from one set of suggestions after it has requested
287 // more but before it's received them. In that case, the entry may not
288 // appear in the new suggestions. But that should happen rarely.
289 browserData.previousFormHistoryResult = suggestions.formHistoryResult;
292 term: suggestions.term,
293 local: suggestions.local,
294 remote: suggestions.remote,
299 async addFormHistoryEntry(browser, entry = null) {
300 let isPrivate = false;
302 // isBrowserPrivate assumes that the passed-in browser has all the normal
303 // properties, which won't be true if the browser has been destroyed.
304 // That may be the case here due to the asynchronous nature of messaging.
305 isPrivate = PrivateBrowsingUtils.isBrowserPrivate(browser);
313 SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH
317 let browserData = this._suggestionDataForBrowser(browser, true);
321 fieldname: browserData.controller.formHistoryParam,
323 source: entry.engineName,
326 handleCompletion: () => {},
327 handleError: err => {
328 Cu.reportError("Error adding form history entry: " + err);
335 async currentStateObj(window) {
338 currentEngine: await this._currentEngineObj(false),
339 currentPrivateEngine: await this._currentEngineObj(true),
342 let pref = Services.prefs.getStringPref("browser.search.hiddenOneOffs");
343 let hiddenList = pref ? pref.split(",") : [];
344 for (let engine of await Services.search.getVisibleEngines()) {
347 iconData: await this._getEngineIconURL(engine),
348 hidden: hiddenList.includes(engine.name),
349 isAppProvided: engine.isAppProvided,
354 state.isPrivateWindow = PrivateBrowsingUtils.isContentWindowPrivate(
362 _processEventQueue() {
363 if (this._currentEventPromise || !this._eventQueue.length) {
367 let event = this._eventQueue.shift();
369 this._currentEventPromise = (async () => {
371 await this["_on" + event.type](event);
375 this._currentEventPromise = null;
377 this._processEventQueue();
382 _cancelSuggestions(browser) {
383 let cancelled = false;
384 // cancel active suggestion request
386 this._currentSuggestion &&
387 this._currentSuggestion.browser === browser
389 this._currentSuggestion.controller.stop();
392 // cancel queued suggestion requests
393 for (let i = 0; i < this._eventQueue.length; i++) {
394 let m = this._eventQueue[i];
395 if (browser === m.browser && m.name === "GetSuggestions") {
396 this._eventQueue.splice(i, 1);
402 this._reply(browser, "SuggestionsCancelled");
406 async _onMessage(eventItem) {
407 let methodName = "_onMessage" + eventItem.name;
408 if (methodName in this) {
409 await this._initService();
410 await this[methodName](eventItem.browser, eventItem.data);
411 eventItem.browser.removeEventListener("SwapDocShells", eventItem, true);
415 _onMessageGetState(browser, data) {
416 return this.currentStateObj(browser.ownerGlobal).then(state => {
417 this._reply(browser, "State", state);
421 _onMessageGetEngine(browser, data) {
422 return this.currentStateObj(browser.ownerGlobal).then(state => {
423 this._reply(browser, "Engine", {
424 isPrivateWindow: state.isPrivateWindow,
425 engine: state.isPrivateWindow
426 ? state.currentPrivateEngine
427 : state.currentEngine,
432 _onMessageGetStrings(browser, data) {
433 this._reply(browser, "Strings", this.searchSuggestionUIStrings);
436 _onMessageSearch(browser, data) {
437 this.performSearch(browser, data);
440 _onMessageSetCurrentEngine(browser, data) {
441 Services.search.defaultEngine = Services.search.getEngineByName(data);
444 _onMessageManageEngines(browser) {
445 browser.ownerGlobal.openPreferences("paneSearch");
448 async _onMessageGetSuggestions(browser, data) {
449 this._ensureDataHasProperties(data, ["engineName", "searchString"]);
450 let { engineName, searchString } = data;
451 let suggestions = await this.getSuggestions(
457 this._reply(browser, "Suggestions", {
458 engineName: data.engineName,
459 searchString: suggestions.term,
460 formHistory: suggestions.local,
461 remote: suggestions.remote,
465 async _onMessageAddFormHistoryEntry(browser, entry) {
466 await this.addFormHistoryEntry(browser, entry);
469 _onMessageRemoveFormHistoryEntry(browser, entry) {
470 this.removeFormHistoryEntry(browser, entry);
473 _onMessageSpeculativeConnect(browser, engineName) {
474 let engine = Services.search.getEngineByName(engineName);
476 throw new Error("Unknown engine name: " + engineName);
478 if (browser.contentWindow) {
479 engine.speculativeConnect({
480 window: browser.contentWindow,
481 originAttributes: browser.contentPrincipal.originAttributes,
486 async _onObserve(eventItem) {
487 if (eventItem.data === "engine-default") {
488 let engine = await this._currentEngineObj(false);
489 this._broadcast("CurrentEngine", engine);
490 } else if (eventItem.data === "engine-default-private") {
491 let engine = await this._currentEngineObj(true);
492 this._broadcast("CurrentPrivateEngine", engine);
494 let state = await this.currentStateObj();
495 this._broadcast("CurrentState", state);
499 _suggestionDataForBrowser(browser, create = false) {
500 let data = this._suggestionMap.get(browser);
501 if (!data && create) {
502 // Since one SearchSuggestionController instance is meant to be used per
503 // autocomplete widget, this means that we assume each xul:browser has at
504 // most one such widget.
506 controller: new SearchSuggestionController(),
508 this._suggestionMap.set(browser, data);
513 _reply(browser, type, data) {
514 browser.sendMessageToActor(type, data, "ContentSearch");
517 _broadcast(type, data) {
518 for (let actor of gContentSearchActors) {
519 actor.sendAsyncMessage(type, data);
523 async _currentEngineObj(usePrivate) {
525 Services.search[usePrivate ? "defaultPrivateEngine" : "defaultEngine"];
528 iconData: await this._getEngineIconURL(engine),
529 isAppProvided: engine.isAppProvided,
535 * Converts the engine's icon into an appropriate URL for display at
537 async _getEngineIconURL(engine) {
538 let url = engine.getIconURLBySize(16, 16);
540 return SEARCH_ENGINE_PLACEHOLDER_ICON;
543 // The uri received here can be of two types
544 // 1 - moz-extension://[uuid]/path/to/icon.ico
545 // 2 - data:image/x-icon;base64,VERY-LONG-STRING
547 // If the URI is not a data: URI, there's no point in converting
548 // it to an arraybuffer (which is used to optimize passing the data
549 // accross processes): we can just pass the original URI, which is cheaper.
550 if (!url.startsWith("data:")) {
554 return new Promise(resolve => {
555 let xhr = new XMLHttpRequest();
556 xhr.open("GET", url, true);
557 xhr.responseType = "arraybuffer";
559 resolve(xhr.response);
561 xhr.onerror = xhr.onabort = xhr.ontimeout = () => {
562 resolve(SEARCH_ENGINE_PLACEHOLDER_ICON);
565 // This throws if the URI is erroneously encoded.
568 resolve(SEARCH_ENGINE_PLACEHOLDER_ICON);
573 _ensureDataHasProperties(data, requiredProperties) {
574 for (let prop of requiredProperties) {
575 if (!(prop in data)) {
576 throw new Error("Message data missing required property: " + prop);
582 if (!this._initServicePromise) {
583 this._initServicePromise = Services.search.init();
585 return this._initServicePromise;
589 class ContentSearchParent extends JSWindowActorParent {
592 ContentSearch.init();
593 gContentSearchActors.add(this);
597 gContentSearchActors.delete(this);
600 receiveMessage(msg) {
601 // Add a temporary event handler that exists only while the message is in
602 // the event queue. If the message's source docshell changes browsers in
603 // the meantime, then we need to update the browser. event.detail will be
604 // the docshell's new parent <xul:browser> element.
605 let browser = this.browsingContext.top.embedderElement;
611 handleEvent: event => {
612 let browserData = ContentSearch._suggestionMap.get(eventItem.browser);
614 ContentSearch._suggestionMap.delete(eventItem.browser);
615 ContentSearch._suggestionMap.set(event.detail, browserData);
617 browser.removeEventListener("SwapDocShells", eventItem, true);
618 eventItem.browser = event.detail;
619 eventItem.browser.addEventListener("SwapDocShells", eventItem, true);
622 browser.addEventListener("SwapDocShells", eventItem, true);
624 // Search requests cause cancellation of all Suggestion requests from the
626 if (msg.name === "Search") {
627 ContentSearch._cancelSuggestions();
630 ContentSearch._eventQueue.push(eventItem);
631 ContentSearch._processEventQueue();