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