summaryrefslogtreecommitdiffstats
path: root/browser/actors/ContentSearchParent.sys.mjs
blob: 73b881881b3ff64f4a4b70cf3a97a9bb6fcb3adb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs",
  FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  SearchSuggestionController:
    "resource://gre/modules/SearchSuggestionController.sys.mjs",
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
});

const MAX_LOCAL_SUGGESTIONS = 3;
const MAX_SUGGESTIONS = 6;
const SEARCH_ENGINE_PLACEHOLDER_ICON =
  "chrome://browser/skin/search-engine-placeholder.png";

// Set of all ContentSearch actors, used to broadcast messages to all of them.
let gContentSearchActors = new Set();

/**
 * Inbound messages have the following types:
 *
 *   AddFormHistoryEntry
 *     Adds an entry to the search form history.
 *     data: the entry, a string
 *   GetSuggestions
 *     Retrieves an array of search suggestions given a search string.
 *     data: { engineName, searchString }
 *   GetState
 *     Retrieves the current search engine state.
 *     data: null
 *   GetStrings
 *     Retrieves localized search UI strings.
 *     data: null
 *   ManageEngines
 *     Opens the search engine management window.
 *     data: null
 *   RemoveFormHistoryEntry
 *     Removes an entry from the search form history.
 *     data: the entry, a string
 *   Search
 *     Performs a search.
 *     Any GetSuggestions messages in the queue from the same target will be
 *     cancelled.
 *     data: { engineName, searchString, healthReportKey, searchPurpose }
 *   SetCurrentEngine
 *     Sets the current engine.
 *     data: the name of the engine
 *   SpeculativeConnect
 *     Speculatively connects to an engine.
 *     data: the name of the engine
 *
 * Outbound messages have the following types:
 *
 *   CurrentEngine
 *     Broadcast when the current engine changes.
 *     data: see _currentEngineObj
 *   CurrentState
 *     Broadcast when the current search state changes.
 *     data: see currentStateObj
 *   State
 *     Sent in reply to GetState.
 *     data: see currentStateObj
 *   Strings
 *     Sent in reply to GetStrings
 *     data: Object containing string names and values for the current locale.
 *   Suggestions
 *     Sent in reply to GetSuggestions.
 *     data: see _onMessageGetSuggestions
 *   SuggestionsCancelled
 *     Sent in reply to GetSuggestions when pending GetSuggestions events are
 *     cancelled.
 *     data: null
 */

export let ContentSearch = {
  initialized: false,

  // Inbound events are queued and processed in FIFO order instead of handling
  // them immediately, which would result in non-FIFO responses due to the
  // asynchrononicity added by converting image data URIs to ArrayBuffers.
  _eventQueue: [],
  _currentEventPromise: null,

  // This is used to handle search suggestions.  It maps xul:browsers to objects
  // { controller, previousFormHistoryResults }.  See _onMessageGetSuggestions.
  _suggestionMap: new WeakMap(),

  // Resolved when we finish shutting down.
  _destroyedPromise: null,

  // The current controller and browser in _onMessageGetSuggestions.  Allows
  // fetch cancellation from _cancelSuggestions.
  _currentSuggestion: null,

  init() {
    if (!this.initialized) {
      Services.obs.addObserver(this, "browser-search-engine-modified");
      Services.obs.addObserver(this, "shutdown-leaks-before-check");
      lazy.UrlbarPrefs.addObserver(this);

      this.initialized = true;
    }
  },

  get searchSuggestionUIStrings() {
    if (this._searchSuggestionUIStrings) {
      return this._searchSuggestionUIStrings;
    }
    this._searchSuggestionUIStrings = {};
    let searchBundle = Services.strings.createBundle(
      "chrome://browser/locale/search.properties"
    );
    let stringNames = [
      "searchHeader",
      "searchForSomethingWith2",
      "searchWithHeader",
      "searchSettings",
    ];

    for (let name of stringNames) {
      this._searchSuggestionUIStrings[name] =
        searchBundle.GetStringFromName(name);
    }
    return this._searchSuggestionUIStrings;
  },

  destroy() {
    if (!this.initialized) {
      return new Promise();
    }

    if (this._destroyedPromise) {
      return this._destroyedPromise;
    }

    Services.obs.removeObserver(this, "browser-search-engine-modified");
    Services.obs.removeObserver(this, "shutdown-leaks-before-check");

    this._eventQueue.length = 0;
    this._destroyedPromise = Promise.resolve(this._currentEventPromise);
    return this._destroyedPromise;
  },

  observe(subj, topic, data) {
    switch (topic) {
      case "browser-search-engine-modified":
        this._eventQueue.push({
          type: "Observe",
          data,
        });
        this._processEventQueue();
        break;
      case "shutdown-leaks-before-check":
        subj.wrappedJSObject.client.addBlocker(
          "ContentSearch: Wait until the service is destroyed",
          () => this.destroy()
        );
        break;
    }
  },

  /**
   * Observes changes in prefs tracked by UrlbarPrefs.
   * @param {string} pref
   *   The name of the pref, relative to `browser.urlbar.` if the pref is
   *   in that branch.
   */
  onPrefChanged(pref) {
    if (lazy.UrlbarPrefs.shouldHandOffToSearchModePrefs.includes(pref)) {
      this._eventQueue.push({
        type: "Observe",
        data: "shouldHandOffToSearchMode",
      });
      this._processEventQueue();
    }
  },

  removeFormHistoryEntry(browser, entry) {
    let browserData = this._suggestionDataForBrowser(browser);
    if (browserData?.previousFormHistoryResults) {
      let result = browserData.previousFormHistoryResults.find(
        e => e.text == entry
      );
      lazy.FormHistory.update({
        op: "remove",
        fieldname: browserData.controller.formHistoryParam,
        value: entry,
        guid: result.guid,
      }).catch(err =>
        console.error("Error removing form history entry: ", err)
      );
    }
  },

  performSearch(actor, browser, data) {
    this._ensureDataHasProperties(data, [
      "engineName",
      "searchString",
      "healthReportKey",
      "searchPurpose",
    ]);
    let engine = Services.search.getEngineByName(data.engineName);
    let submission = engine.getSubmission(
      data.searchString,
      "",
      data.searchPurpose
    );
    let win = browser.ownerGlobal;
    if (!win) {
      // The browser may have been closed between the time its content sent the
      // message and the time we handle it.
      return;
    }
    let where = win.whereToOpenLink(data.originalEvent);

    // There is a chance that by the time we receive the search message, the user
    // has switched away from the tab that triggered the search. If, based on the
    // event, we need to load the search in the same tab that triggered it (i.e.
    // where === "current"), openUILinkIn will not work because that tab is no
    // longer the current one. For this case we manually load the URI.
    if (where === "current") {
      // Since we're going to load the search in the same browser, blur the search
      // UI to prevent further interaction before we start loading.
      this._reply(actor, "Blur");
      browser.loadURI(submission.uri, {
        postData: submission.postData,
        triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
          {
            userContextId:
              win.gBrowser.selectedBrowser.getAttribute("userContextId"),
          }
        ),
      });
    } else {
      let params = {
        postData: submission.postData,
        inBackground: Services.prefs.getBoolPref(
          "browser.tabs.loadInBackground"
        ),
      };
      win.openTrustedLinkIn(submission.uri.spec, where, params);
    }
    lazy.BrowserSearchTelemetry.recordSearch(
      browser,
      engine,
      data.healthReportKey,
      {
        selection: data.selection,
      }
    );
  },

  async getSuggestions(engineName, searchString, browser) {
    let engine = Services.search.getEngineByName(engineName);
    if (!engine) {
      throw new Error("Unknown engine name: " + engineName);
    }

    let browserData = this._suggestionDataForBrowser(browser, true);
    let { controller } = browserData;
    let ok = lazy.SearchSuggestionController.engineOffersSuggestions(engine);
    controller.maxLocalResults = ok ? MAX_LOCAL_SUGGESTIONS : MAX_SUGGESTIONS;
    controller.maxRemoteResults = ok ? MAX_SUGGESTIONS : 0;
    let priv = lazy.PrivateBrowsingUtils.isBrowserPrivate(browser);
    // fetch() rejects its promise if there's a pending request, but since we
    // process our event queue serially, there's never a pending request.
    this._currentSuggestion = { controller, browser };
    let suggestions = await controller.fetch(searchString, priv, engine);

    // Simplify results since we do not support rich results in this component.
    suggestions.local = suggestions.local.map(e => e.value);
    // We shouldn't show tail suggestions in their full-text form.
    let nonTailEntries = suggestions.remote.filter(
      e => !e.matchPrefix && !e.tail
    );
    suggestions.remote = nonTailEntries.map(e => e.value);

    this._currentSuggestion = null;

    // suggestions will be null if the request was cancelled
    let result = {};
    if (!suggestions) {
      return result;
    }

    // Keep the form history results so RemoveFormHistoryEntry can remove entries
    // from it.  Keeping only one result isn't foolproof because the client may
    // try to remove an entry from one set of suggestions after it has requested
    // more but before it's received them.  In that case, the entry may not
    // appear in the new suggestions.  But that should happen rarely.
    browserData.previousFormHistoryResults = suggestions.formHistoryResults;
    result = {
      engineName,
      term: suggestions.term,
      local: suggestions.local,
      remote: suggestions.remote,
    };
    return result;
  },

  async addFormHistoryEntry(browser, entry = null) {
    let isPrivate = false;
    try {
      // isBrowserPrivate assumes that the passed-in browser has all the normal
      // properties, which won't be true if the browser has been destroyed.
      // That may be the case here due to the asynchronous nature of messaging.
      isPrivate = lazy.PrivateBrowsingUtils.isBrowserPrivate(browser);
    } catch (err) {
      return false;
    }
    if (
      isPrivate ||
      !entry ||
      entry.value.length >
        lazy.SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH
    ) {
      return false;
    }
    let browserData = this._suggestionDataForBrowser(browser, true);
    lazy.FormHistory.update({
      op: "bump",
      fieldname: browserData.controller.formHistoryParam,
      value: entry.value,
      source: entry.engineName,
    }).catch(err => console.error("Error adding form history entry: ", err));
    return true;
  },

  async currentStateObj(window) {
    let state = {
      engines: [],
      currentEngine: await this._currentEngineObj(false),
      currentPrivateEngine: await this._currentEngineObj(true),
    };

    for (let engine of await Services.search.getVisibleEngines()) {
      state.engines.push({
        name: engine.name,
        iconData: await this._getEngineIconURL(engine),
        hidden: engine.hideOneOffButton,
        isAppProvided: engine.isAppProvided,
      });
    }

    if (window) {
      state.isInPrivateBrowsingMode =
        lazy.PrivateBrowsingUtils.isContentWindowPrivate(window);
      state.isAboutPrivateBrowsing =
        window.gBrowser.currentURI.spec == "about:privatebrowsing";
    }

    return state;
  },

  _processEventQueue() {
    if (this._currentEventPromise || !this._eventQueue.length) {
      return;
    }

    let event = this._eventQueue.shift();

    this._currentEventPromise = (async () => {
      try {
        await this["_on" + event.type](event);
      } catch (err) {
        console.error(err);
      } finally {
        this._currentEventPromise = null;

        this._processEventQueue();
      }
    })();
  },

  _cancelSuggestions({ actor, browser }) {
    let cancelled = false;
    // cancel active suggestion request
    if (
      this._currentSuggestion &&
      this._currentSuggestion.browser === browser
    ) {
      this._currentSuggestion.controller.stop();
      cancelled = true;
    }
    // cancel queued suggestion requests
    for (let i = 0; i < this._eventQueue.length; i++) {
      let m = this._eventQueue[i];
      if (actor === m.actor && m.name === "GetSuggestions") {
        this._eventQueue.splice(i, 1);
        cancelled = true;
        i--;
      }
    }
    if (cancelled) {
      this._reply(actor, "SuggestionsCancelled");
    }
  },

  async _onMessage(eventItem) {
    let methodName = "_onMessage" + eventItem.name;
    if (methodName in this) {
      await this._initService();
      await this[methodName](eventItem);
      eventItem.browser.removeEventListener("SwapDocShells", eventItem, true);
    }
  },

  _onMessageGetState({ actor, browser }) {
    return this.currentStateObj(browser.ownerGlobal).then(state => {
      this._reply(actor, "State", state);
    });
  },

  _onMessageGetEngine({ actor, browser }) {
    return this.currentStateObj(browser.ownerGlobal).then(state => {
      this._reply(actor, "Engine", {
        isPrivateEngine: state.isInPrivateBrowsingMode,
        isAboutPrivateBrowsing: state.isAboutPrivateBrowsing,
        engine: state.isInPrivateBrowsingMode
          ? state.currentPrivateEngine
          : state.currentEngine,
      });
    });
  },

  _onMessageGetHandoffSearchModePrefs({ actor }) {
    this._reply(
      actor,
      "HandoffSearchModePrefs",
      lazy.UrlbarPrefs.get("shouldHandOffToSearchMode")
    );
  },

  _onMessageGetStrings({ actor }) {
    this._reply(actor, "Strings", this.searchSuggestionUIStrings);
  },

  _onMessageSearch({ actor, browser, data }) {
    this.performSearch(actor, browser, data);
  },

  _onMessageSetCurrentEngine({ data }) {
    Services.search.setDefault(
      Services.search.getEngineByName(data),
      Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR
    );
  },

  _onMessageManageEngines({ browser }) {
    browser.ownerGlobal.openPreferences("paneSearch");
  },

  async _onMessageGetSuggestions({ actor, browser, data }) {
    this._ensureDataHasProperties(data, ["engineName", "searchString"]);
    let { engineName, searchString } = data;
    let suggestions = await this.getSuggestions(
      engineName,
      searchString,
      browser
    );

    this._reply(actor, "Suggestions", {
      engineName: data.engineName,
      searchString: suggestions.term,
      formHistory: suggestions.local,
      remote: suggestions.remote,
    });
  },

  async _onMessageAddFormHistoryEntry({ browser, data: entry }) {
    await this.addFormHistoryEntry(browser, entry);
  },

  _onMessageRemoveFormHistoryEntry({ browser, data: entry }) {
    this.removeFormHistoryEntry(browser, entry);
  },

  _onMessageSpeculativeConnect({ browser, data: engineName }) {
    let engine = Services.search.getEngineByName(engineName);
    if (!engine) {
      throw new Error("Unknown engine name: " + engineName);
    }
    if (browser.contentWindow) {
      engine.speculativeConnect({
        window: browser.contentWindow,
        originAttributes: browser.contentPrincipal.originAttributes,
      });
    }
  },

  async _onObserve(eventItem) {
    let engine;
    switch (eventItem.data) {
      case "engine-default":
        engine = await this._currentEngineObj(false);
        this._broadcast("CurrentEngine", engine);
        break;
      case "engine-default-private":
        engine = await this._currentEngineObj(true);
        this._broadcast("CurrentPrivateEngine", engine);
        break;
      case "shouldHandOffToSearchMode":
        this._broadcast(
          "HandoffSearchModePrefs",
          lazy.UrlbarPrefs.get("shouldHandOffToSearchMode")
        );
        break;
      default:
        let state = await this.currentStateObj();
        this._broadcast("CurrentState", state);
        break;
    }
  },

  _suggestionDataForBrowser(browser, create = false) {
    let data = this._suggestionMap.get(browser);
    if (!data && create) {
      // Since one SearchSuggestionController instance is meant to be used per
      // autocomplete widget, this means that we assume each xul:browser has at
      // most one such widget.
      data = {
        controller: new lazy.SearchSuggestionController(),
      };
      this._suggestionMap.set(browser, data);
    }
    return data;
  },

  _reply(actor, type, data) {
    actor.sendAsyncMessage(type, data);
  },

  _broadcast(type, data) {
    for (let actor of gContentSearchActors) {
      actor.sendAsyncMessage(type, data);
    }
  },

  async _currentEngineObj(usePrivate) {
    let engine =
      Services.search[usePrivate ? "defaultPrivateEngine" : "defaultEngine"];
    let obj = {
      name: engine.name,
      iconData: await this._getEngineIconURL(engine),
      isAppProvided: engine.isAppProvided,
    };
    return obj;
  },

  /**
   * Converts the engine's icon into a URL or an ArrayBuffer for passing to the
   * content process.
   *
   * @param {nsISearchEngine} engine
   *   The engine to get the icon for.
   * @returns {string|ArrayBuffer}
   *   The icon's URL or an ArrayBuffer containing the icon data.
   */
  async _getEngineIconURL(engine) {
    let url = await engine.getIconURL();
    if (!url) {
      return SEARCH_ENGINE_PLACEHOLDER_ICON;
    }

    // The uri received here can be one of several types:
    // 1 - moz-extension://[uuid]/path/to/icon.ico
    // 2 - data:image/x-icon;base64,VERY-LONG-STRING
    // 3 - blob:
    //
    // For moz-extension URIs we can pass the URI to the content process and
    // use it directly as they can be accessed from there and it is cheaper.
    //
    // For blob URIs the content process is a different scope and we can't share
    // the blob with that scope. Hence we have to create a copy of the data.
    //
    // For data: URIs we convert to an ArrayBuffer as that is more optimal for
    // passing the data across to the content process.
    if (!url.startsWith("data:") && !url.startsWith("blob:")) {
      return url;
    }

    return new Promise(resolve => {
      let xhr = new XMLHttpRequest();
      xhr.open("GET", url, true);
      xhr.responseType = "arraybuffer";
      xhr.onload = () => {
        resolve(xhr.response);
      };
      xhr.onerror =
        xhr.onabort =
        xhr.ontimeout =
          () => {
            resolve(SEARCH_ENGINE_PLACEHOLDER_ICON);
          };
      try {
        // This throws if the URI is erroneously encoded.
        xhr.send();
      } catch (err) {
        resolve(SEARCH_ENGINE_PLACEHOLDER_ICON);
      }
    });
  },

  _ensureDataHasProperties(data, requiredProperties) {
    for (let prop of requiredProperties) {
      if (!(prop in data)) {
        throw new Error("Message data missing required property: " + prop);
      }
    }
  },

  _initService() {
    if (!this._initServicePromise) {
      this._initServicePromise = Services.search.init();
    }
    return this._initServicePromise;
  },
};

export class ContentSearchParent extends JSWindowActorParent {
  constructor() {
    super();
    ContentSearch.init();
    gContentSearchActors.add(this);
  }

  didDestroy() {
    gContentSearchActors.delete(this);
  }

  receiveMessage(msg) {
    // Add a temporary event handler that exists only while the message is in
    // the event queue.  If the message's source docshell changes browsers in
    // the meantime, then we need to update the browser.  event.detail will be
    // the docshell's new parent <xul:browser> element.
    let browser = this.browsingContext.top.embedderElement;
    let eventItem = {
      type: "Message",
      name: msg.name,
      data: msg.data,
      browser,
      actor: this,
      handleEvent: event => {
        let browserData = ContentSearch._suggestionMap.get(eventItem.browser);
        if (browserData) {
          ContentSearch._suggestionMap.delete(eventItem.browser);
          ContentSearch._suggestionMap.set(event.detail, browserData);
        }
        browser.removeEventListener("SwapDocShells", eventItem, true);
        eventItem.browser = event.detail;
        eventItem.browser.addEventListener("SwapDocShells", eventItem, true);
      },
    };
    browser.addEventListener("SwapDocShells", eventItem, true);

    // Search requests cause cancellation of all Suggestion requests from the
    // same browser.
    if (msg.name === "Search") {
      ContentSearch._cancelSuggestions(eventItem);
    }

    ContentSearch._eventQueue.push(eventItem);
    ContentSearch._processEventQueue();
  }
}