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