Bug 1685680 [wpt PR 27099] - Add missing jsapi tests for wasm reference-types proposa...
[gecko.git] / browser / actors / ContentSearchParent.jsm
blob3f9b3456a2e3ba56def2bb1f01fc25f74beeeb3c
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";
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",
21 });
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();
31 /**
32  * Inbound messages have the following types:
33  *
34  *   AddFormHistoryEntry
35  *     Adds an entry to the search form history.
36  *     data: the entry, a string
37  *   GetSuggestions
38  *     Retrieves an array of search suggestions given a search string.
39  *     data: { engineName, searchString }
40  *   GetState
41  *     Retrieves the current search engine state.
42  *     data: null
43  *   GetStrings
44  *     Retrieves localized search UI strings.
45  *     data: null
46  *   ManageEngines
47  *     Opens the search engine management window.
48  *     data: null
49  *   RemoveFormHistoryEntry
50  *     Removes an entry from the search form history.
51  *     data: the entry, a string
52  *   Search
53  *     Performs a search.
54  *     Any GetSuggestions messages in the queue from the same target will be
55  *     cancelled.
56  *     data: { engineName, searchString, healthReportKey, searchPurpose }
57  *   SetCurrentEngine
58  *     Sets the current engine.
59  *     data: the name of the engine
60  *   SpeculativeConnect
61  *     Speculatively connects to an engine.
62  *     data: the name of the engine
63  *
64  * Outbound messages have the following types:
65  *
66  *   CurrentEngine
67  *     Broadcast when the current engine changes.
68  *     data: see _currentEngineObj
69  *   CurrentState
70  *     Broadcast when the current search state changes.
71  *     data: see currentStateObj
72  *   State
73  *     Sent in reply to GetState.
74  *     data: see currentStateObj
75  *   Strings
76  *     Sent in reply to GetStrings
77  *     data: Object containing string names and values for the current locale.
78  *   Suggestions
79  *     Sent in reply to GetSuggestions.
80  *     data: see _onMessageGetSuggestions
81  *   SuggestionsCancelled
82  *     Sent in reply to GetSuggestions when pending GetSuggestions events are
83  *     cancelled.
84  *     data: null
85  */
87 let ContentSearch = {
88   initialized: false,
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.
93   _eventQueue: [],
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,
107   init() {
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;
115     }
116   },
118   get searchSuggestionUIStrings() {
119     if (this._searchSuggestionUIStrings) {
120       return this._searchSuggestionUIStrings;
121     }
122     this._searchSuggestionUIStrings = {};
123     let searchBundle = Services.strings.createBundle(
124       "chrome://browser/locale/search.properties"
125     );
126     let stringNames = [
127       "searchHeader",
128       "searchForSomethingWith2",
129       "searchWithHeader",
130       "searchSettings",
131     ];
133     for (let name of stringNames) {
134       this._searchSuggestionUIStrings[name] = searchBundle.GetStringFromName(
135         name
136       );
137     }
138     return this._searchSuggestionUIStrings;
139   },
141   destroy() {
142     if (!this.initialized) {
143       return new Promise();
144     }
146     if (this._destroyedPromise) {
147       return this._destroyedPromise;
148     }
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;
157   },
159   observe(subj, topic, data) {
160     switch (topic) {
161       case "browser-search-service":
162         if (data != "init-complete") {
163           break;
164         }
165       // fall through
166       case "nsPref:changed":
167       case "browser-search-engine-modified":
168         this._eventQueue.push({
169           type: "Observe",
170           data,
171         });
172         this._processEventQueue();
173         break;
174       case "shutdown-leaks-before-check":
175         subj.wrappedJSObject.client.addBlocker(
176           "ContentSearch: Wait until the service is destroyed",
177           () => this.destroy()
178         );
179         break;
180     }
181   },
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);
190           break;
191         }
192       }
193     }
194   },
196   performSearch(browser, data) {
197     this._ensureDataHasProperties(data, [
198       "engineName",
199       "searchString",
200       "healthReportKey",
201       "searchPurpose",
202     ]);
203     let engine = Services.search.getEngineByName(data.engineName);
204     let submission = engine.getSubmission(
205       data.searchString,
206       "",
207       data.searchPurpose
208     );
209     let win = browser.ownerGlobal;
210     if (!win) {
211       // The browser may have been closed between the time its content sent the
212       // message and the time we handle it.
213       return;
214     }
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(
229           {
230             userContextId: win.gBrowser.selectedBrowser.getAttribute(
231               "userContextId"
232             ),
233           }
234         ),
235       });
236     } else {
237       let params = {
238         postData: submission.postData,
239         inBackground: Services.prefs.getBoolPref(
240           "browser.tabs.loadInBackground"
241         ),
242       };
243       win.openTrustedLinkIn(submission.uri.spec, where, params);
244     }
245     BrowserSearchTelemetry.recordSearch(browser, engine, data.healthReportKey, {
246       selection: data.selection,
247       url: submission.uri,
248     });
249   },
251   async getSuggestions(engineName, searchString, browser) {
252     let engine = Services.search.getEngineByName(engineName);
253     if (!engine) {
254       throw new Error("Unknown engine name: " + engineName);
255     }
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
273     );
274     suggestions.remote = nonTailEntries.map(e => e.value);
276     this._currentSuggestion = null;
278     // suggestions will be null if the request was cancelled
279     let result = {};
280     if (!suggestions) {
281       return result;
282     }
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;
290     result = {
291       engineName,
292       term: suggestions.term,
293       local: suggestions.local,
294       remote: suggestions.remote,
295     };
296     return result;
297   },
299   async addFormHistoryEntry(browser, entry = null) {
300     let isPrivate = false;
301     try {
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);
306     } catch (err) {
307       return false;
308     }
309     if (
310       isPrivate ||
311       !entry ||
312       entry.value.length >
313         SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH
314     ) {
315       return false;
316     }
317     let browserData = this._suggestionDataForBrowser(browser, true);
318     FormHistory.update(
319       {
320         op: "bump",
321         fieldname: browserData.controller.formHistoryParam,
322         value: entry.value,
323         source: entry.engineName,
324       },
325       {
326         handleCompletion: () => {},
327         handleError: err => {
328           Cu.reportError("Error adding form history entry: " + err);
329         },
330       }
331     );
332     return true;
333   },
335   async currentStateObj(window) {
336     let state = {
337       engines: [],
338       currentEngine: await this._currentEngineObj(false),
339       currentPrivateEngine: await this._currentEngineObj(true),
340     };
342     let pref = Services.prefs.getStringPref("browser.search.hiddenOneOffs");
343     let hiddenList = pref ? pref.split(",") : [];
344     for (let engine of await Services.search.getVisibleEngines()) {
345       state.engines.push({
346         name: engine.name,
347         iconData: await this._getEngineIconURL(engine),
348         hidden: hiddenList.includes(engine.name),
349         isAppProvided: engine.isAppProvided,
350       });
351     }
353     if (window) {
354       state.isPrivateWindow = PrivateBrowsingUtils.isContentWindowPrivate(
355         window
356       );
357     }
359     return state;
360   },
362   _processEventQueue() {
363     if (this._currentEventPromise || !this._eventQueue.length) {
364       return;
365     }
367     let event = this._eventQueue.shift();
369     this._currentEventPromise = (async () => {
370       try {
371         await this["_on" + event.type](event);
372       } catch (err) {
373         Cu.reportError(err);
374       } finally {
375         this._currentEventPromise = null;
377         this._processEventQueue();
378       }
379     })();
380   },
382   _cancelSuggestions(browser) {
383     let cancelled = false;
384     // cancel active suggestion request
385     if (
386       this._currentSuggestion &&
387       this._currentSuggestion.browser === browser
388     ) {
389       this._currentSuggestion.controller.stop();
390       cancelled = true;
391     }
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);
397         cancelled = true;
398         i--;
399       }
400     }
401     if (cancelled) {
402       this._reply(browser, "SuggestionsCancelled");
403     }
404   },
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);
412     }
413   },
415   _onMessageGetState(browser, data) {
416     return this.currentStateObj(browser.ownerGlobal).then(state => {
417       this._reply(browser, "State", state);
418     });
419   },
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,
428       });
429     });
430   },
432   _onMessageGetStrings(browser, data) {
433     this._reply(browser, "Strings", this.searchSuggestionUIStrings);
434   },
436   _onMessageSearch(browser, data) {
437     this.performSearch(browser, data);
438   },
440   _onMessageSetCurrentEngine(browser, data) {
441     Services.search.defaultEngine = Services.search.getEngineByName(data);
442   },
444   _onMessageManageEngines(browser) {
445     browser.ownerGlobal.openPreferences("paneSearch");
446   },
448   async _onMessageGetSuggestions(browser, data) {
449     this._ensureDataHasProperties(data, ["engineName", "searchString"]);
450     let { engineName, searchString } = data;
451     let suggestions = await this.getSuggestions(
452       engineName,
453       searchString,
454       browser
455     );
457     this._reply(browser, "Suggestions", {
458       engineName: data.engineName,
459       searchString: suggestions.term,
460       formHistory: suggestions.local,
461       remote: suggestions.remote,
462     });
463   },
465   async _onMessageAddFormHistoryEntry(browser, entry) {
466     await this.addFormHistoryEntry(browser, entry);
467   },
469   _onMessageRemoveFormHistoryEntry(browser, entry) {
470     this.removeFormHistoryEntry(browser, entry);
471   },
473   _onMessageSpeculativeConnect(browser, engineName) {
474     let engine = Services.search.getEngineByName(engineName);
475     if (!engine) {
476       throw new Error("Unknown engine name: " + engineName);
477     }
478     if (browser.contentWindow) {
479       engine.speculativeConnect({
480         window: browser.contentWindow,
481         originAttributes: browser.contentPrincipal.originAttributes,
482       });
483     }
484   },
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);
493     } else {
494       let state = await this.currentStateObj();
495       this._broadcast("CurrentState", state);
496     }
497   },
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.
505       data = {
506         controller: new SearchSuggestionController(),
507       };
508       this._suggestionMap.set(browser, data);
509     }
510     return data;
511   },
513   _reply(browser, type, data) {
514     browser.sendMessageToActor(type, data, "ContentSearch");
515   },
517   _broadcast(type, data) {
518     for (let actor of gContentSearchActors) {
519       actor.sendAsyncMessage(type, data);
520     }
521   },
523   async _currentEngineObj(usePrivate) {
524     let engine =
525       Services.search[usePrivate ? "defaultPrivateEngine" : "defaultEngine"];
526     let obj = {
527       name: engine.name,
528       iconData: await this._getEngineIconURL(engine),
529       isAppProvided: engine.isAppProvided,
530     };
531     return obj;
532   },
534   /**
535    * Converts the engine's icon into an appropriate URL for display at
536    */
537   async _getEngineIconURL(engine) {
538     let url = engine.getIconURLBySize(16, 16);
539     if (!url) {
540       return SEARCH_ENGINE_PLACEHOLDER_ICON;
541     }
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
546     //
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:")) {
551       return url;
552     }
554     return new Promise(resolve => {
555       let xhr = new XMLHttpRequest();
556       xhr.open("GET", url, true);
557       xhr.responseType = "arraybuffer";
558       xhr.onload = () => {
559         resolve(xhr.response);
560       };
561       xhr.onerror = xhr.onabort = xhr.ontimeout = () => {
562         resolve(SEARCH_ENGINE_PLACEHOLDER_ICON);
563       };
564       try {
565         // This throws if the URI is erroneously encoded.
566         xhr.send();
567       } catch (err) {
568         resolve(SEARCH_ENGINE_PLACEHOLDER_ICON);
569       }
570     });
571   },
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);
577       }
578     }
579   },
581   _initService() {
582     if (!this._initServicePromise) {
583       this._initServicePromise = Services.search.init();
584     }
585     return this._initServicePromise;
586   },
589 class ContentSearchParent extends JSWindowActorParent {
590   constructor() {
591     super();
592     ContentSearch.init();
593     gContentSearchActors.add(this);
594   }
596   didDestroy() {
597     gContentSearchActors.delete(this);
598   }
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;
606     let eventItem = {
607       type: "Message",
608       name: msg.name,
609       data: msg.data,
610       browser,
611       handleEvent: event => {
612         let browserData = ContentSearch._suggestionMap.get(eventItem.browser);
613         if (browserData) {
614           ContentSearch._suggestionMap.delete(eventItem.browser);
615           ContentSearch._suggestionMap.set(event.detail, browserData);
616         }
617         browser.removeEventListener("SwapDocShells", eventItem, true);
618         eventItem.browser = event.detail;
619         eventItem.browser.addEventListener("SwapDocShells", eventItem, true);
620       },
621     };
622     browser.addEventListener("SwapDocShells", eventItem, true);
624     // Search requests cause cancellation of all Suggestion requests from the
625     // same browser.
626     if (msg.name === "Search") {
627       ContentSearch._cancelSuggestions();
628     }
630     ContentSearch._eventQueue.push(eventItem);
631     ContentSearch._processEventQueue();
632   }