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/. */
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",
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();
25 * Inbound messages have the following types:
28 * Adds an entry to the search form history.
29 * data: the entry, a string
31 * Retrieves an array of search suggestions given a search string.
32 * data: { engineName, searchString }
34 * Retrieves the current search engine state.
37 * Retrieves localized search UI strings.
40 * Opens the search engine management window.
42 * RemoveFormHistoryEntry
43 * Removes an entry from the search form history.
44 * data: the entry, a string
47 * Any GetSuggestions messages in the queue from the same target will be
49 * data: { engineName, searchString, healthReportKey, searchPurpose }
51 * Sets the current engine.
52 * data: the name of the engine
54 * Speculatively connects to an engine.
55 * data: the name of the engine
57 * Outbound messages have the following types:
60 * Broadcast when the current engine changes.
61 * data: see _currentEngineObj
63 * Broadcast when the current search state changes.
64 * data: see currentStateObj
66 * Sent in reply to GetState.
67 * data: see currentStateObj
69 * Sent in reply to GetStrings
70 * data: Object containing string names and values for the current locale.
72 * Sent in reply to GetSuggestions.
73 * data: see _onMessageGetSuggestions
74 * SuggestionsCancelled
75 * Sent in reply to GetSuggestions when pending GetSuggestions events are
80 export let ContentSearch = {
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.
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,
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;
110 get searchSuggestionUIStrings() {
111 if (this._searchSuggestionUIStrings) {
112 return this._searchSuggestionUIStrings;
114 this._searchSuggestionUIStrings = {};
115 let searchBundle = Services.strings.createBundle(
116 "chrome://browser/locale/search.properties"
120 "searchForSomethingWith2",
125 for (let name of stringNames) {
126 this._searchSuggestionUIStrings[name] =
127 searchBundle.GetStringFromName(name);
129 return this._searchSuggestionUIStrings;
133 if (!this.initialized) {
134 return new Promise();
137 if (this._destroyedPromise) {
138 return this._destroyedPromise;
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;
149 observe(subj, topic, data) {
151 case "browser-search-engine-modified":
152 this._eventQueue.push({
156 this._processEventQueue();
158 case "shutdown-leaks-before-check":
159 subj.wrappedJSObject.client.addBlocker(
160 "ContentSearch: Wait until the service is destroyed",
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
173 onPrefChanged(pref) {
174 if (lazy.UrlbarPrefs.shouldHandOffToSearchModePrefs.includes(pref)) {
175 this._eventQueue.push({
177 data: "shouldHandOffToSearchMode",
179 this._processEventQueue();
183 removeFormHistoryEntry(browser, entry) {
184 let browserData = this._suggestionDataForBrowser(browser);
185 if (browserData?.previousFormHistoryResults) {
186 let result = browserData.previousFormHistoryResults.find(
189 lazy.FormHistory.update({
191 fieldname: browserData.controller.formHistoryParam,
195 console.error("Error removing form history entry: ", err)
200 performSearch(actor, browser, data) {
201 this._ensureDataHasProperties(data, [
207 let engine = Services.search.getEngineByName(data.engineName);
208 let submission = engine.getSubmission(
213 let win = browser.ownerGlobal;
215 // The browser may have been closed between the time its content sent the
216 // message and the time we handle it.
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(
235 win.gBrowser.selectedBrowser.getAttribute("userContextId"),
241 postData: submission.postData,
242 inBackground: Services.prefs.getBoolPref(
243 "browser.tabs.loadInBackground"
246 win.openTrustedLinkIn(submission.uri.spec, where, params);
248 lazy.BrowserSearchTelemetry.recordSearch(
251 data.healthReportKey,
253 selection: data.selection,
258 async getSuggestions(engineName, searchString, browser) {
259 let engine = Services.search.getEngineByName(engineName);
261 throw new Error("Unknown engine name: " + engineName);
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
281 suggestions.remote = nonTailEntries.map(e => e.value);
283 this._currentSuggestion = null;
285 // suggestions will be null if the request was cancelled
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;
299 term: suggestions.term,
300 local: suggestions.local,
301 remote: suggestions.remote,
306 async addFormHistoryEntry(browser, entry = null) {
307 let isPrivate = false;
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);
320 lazy.SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH
324 let browserData = this._suggestionDataForBrowser(browser, true);
325 lazy.FormHistory.update({
327 fieldname: browserData.controller.formHistoryParam,
329 source: entry.engineName,
330 }).catch(err => console.error("Error adding form history entry: ", err));
334 async currentStateObj(window) {
337 currentEngine: await this._currentEngineObj(false),
338 currentPrivateEngine: await this._currentEngineObj(true),
341 for (let engine of await Services.search.getVisibleEngines()) {
344 iconData: await this._getEngineIconURL(engine),
345 hidden: engine.hideOneOffButton,
346 isAppProvided: engine.isAppProvided,
351 state.isInPrivateBrowsingMode =
352 lazy.PrivateBrowsingUtils.isContentWindowPrivate(window);
353 state.isAboutPrivateBrowsing =
354 window.gBrowser.currentURI.spec == "about:privatebrowsing";
360 _processEventQueue() {
361 if (this._currentEventPromise || !this._eventQueue.length) {
365 let event = this._eventQueue.shift();
367 this._currentEventPromise = (async () => {
369 await this["_on" + event.type](event);
373 this._currentEventPromise = null;
375 this._processEventQueue();
380 _cancelSuggestions({ actor, browser }) {
381 let cancelled = false;
382 // cancel active suggestion request
384 this._currentSuggestion &&
385 this._currentSuggestion.browser === browser
387 this._currentSuggestion.controller.stop();
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);
400 this._reply(actor, "SuggestionsCancelled");
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);
413 _onMessageGetState({ actor, browser }) {
414 return this.currentStateObj(browser.ownerGlobal).then(state => {
415 this._reply(actor, "State", state);
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,
431 _onMessageGetHandoffSearchModePrefs({ actor }) {
434 "HandoffSearchModePrefs",
435 lazy.UrlbarPrefs.get("shouldHandOffToSearchMode")
439 _onMessageGetStrings({ actor }) {
440 this._reply(actor, "Strings", this.searchSuggestionUIStrings);
443 _onMessageSearch({ actor, browser, data }) {
444 this.performSearch(actor, browser, data);
447 _onMessageSetCurrentEngine({ data }) {
448 Services.search.setDefault(
449 Services.search.getEngineByName(data),
450 Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR
454 _onMessageManageEngines({ browser }) {
455 browser.ownerGlobal.openPreferences("paneSearch");
458 async _onMessageGetSuggestions({ actor, browser, data }) {
459 this._ensureDataHasProperties(data, ["engineName", "searchString"]);
460 let { engineName, searchString } = data;
461 let suggestions = await this.getSuggestions(
467 this._reply(actor, "Suggestions", {
468 engineName: data.engineName,
469 searchString: suggestions.term,
470 formHistory: suggestions.local,
471 remote: suggestions.remote,
475 async _onMessageAddFormHistoryEntry({ browser, data: entry }) {
476 await this.addFormHistoryEntry(browser, entry);
479 _onMessageRemoveFormHistoryEntry({ browser, data: entry }) {
480 this.removeFormHistoryEntry(browser, entry);
483 _onMessageSpeculativeConnect({ browser, data: engineName }) {
484 let engine = Services.search.getEngineByName(engineName);
486 throw new Error("Unknown engine name: " + engineName);
488 if (browser.contentWindow) {
489 engine.speculativeConnect({
490 window: browser.contentWindow,
491 originAttributes: browser.contentPrincipal.originAttributes,
496 async _onObserve(eventItem) {
498 switch (eventItem.data) {
499 case "engine-default":
500 engine = await this._currentEngineObj(false);
501 this._broadcast("CurrentEngine", engine);
503 case "engine-default-private":
504 engine = await this._currentEngineObj(true);
505 this._broadcast("CurrentPrivateEngine", engine);
507 case "shouldHandOffToSearchMode":
509 "HandoffSearchModePrefs",
510 lazy.UrlbarPrefs.get("shouldHandOffToSearchMode")
514 let state = await this.currentStateObj();
515 this._broadcast("CurrentState", state);
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.
527 controller: new lazy.SearchSuggestionController(),
529 this._suggestionMap.set(browser, data);
534 _reply(actor, type, data) {
535 actor.sendAsyncMessage(type, data);
538 _broadcast(type, data) {
539 for (let actor of gContentSearchActors) {
540 actor.sendAsyncMessage(type, data);
544 async _currentEngineObj(usePrivate) {
546 Services.search[usePrivate ? "defaultPrivateEngine" : "defaultEngine"];
549 iconData: await this._getEngineIconURL(engine),
550 isAppProvided: engine.isAppProvided,
556 * Converts the engine's icon into a URL or an ArrayBuffer for passing to the
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.
564 async _getEngineIconURL(engine) {
565 let url = await engine.getIconURL();
567 return SEARCH_ENGINE_PLACEHOLDER_ICON;
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
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.
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.
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:")) {
587 return new Promise(resolve => {
588 let xhr = new XMLHttpRequest();
589 xhr.open("GET", url, true);
590 xhr.responseType = "arraybuffer";
592 resolve(xhr.response);
598 resolve(SEARCH_ENGINE_PLACEHOLDER_ICON);
601 // This throws if the URI is erroneously encoded.
604 resolve(SEARCH_ENGINE_PLACEHOLDER_ICON);
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);
618 if (!this._initServicePromise) {
619 this._initServicePromise = Services.search.init();
621 return this._initServicePromise;
625 export class ContentSearchParent extends JSWindowActorParent {
628 ContentSearch.init();
629 gContentSearchActors.add(this);
633 gContentSearchActors.delete(this);
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;
648 handleEvent: event => {
649 let browserData = ContentSearch._suggestionMap.get(eventItem.browser);
651 ContentSearch._suggestionMap.delete(eventItem.browser);
652 ContentSearch._suggestionMap.set(event.detail, browserData);
654 browser.removeEventListener("SwapDocShells", eventItem, true);
655 eventItem.browser = event.detail;
656 eventItem.browser.addEventListener("SwapDocShells", eventItem, true);
659 browser.addEventListener("SwapDocShells", eventItem, true);
661 // Search requests cause cancellation of all Suggestion requests from the
663 if (msg.name === "Search") {
664 ContentSearch._cancelSuggestions(eventItem);
667 ContentSearch._eventQueue.push(eventItem);
668 ContentSearch._processEventQueue();