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
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
9 XPCOMUtils.defineLazyPreferenceGetter(
11 "DELEGATE_AUTOCOMPLETE",
12 "toolkit.autocomplete.delegate",
16 ChromeUtils.defineESModuleGetters(lazy, {
17 GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs",
18 setTimeout: "resource://gre/modules/Timer.sys.mjs",
21 const PREF_SECURITY_DELAY = "security.notification_enable_delay";
23 // Stores the actor that has the active popup, used by formfill
24 let currentActor = null;
26 let autoCompleteListeners = new Set();
28 function compareContext(message) {
31 (currentActor.browsingContext != message.data.browsingContext &&
32 currentActor.browsingContext.top != message.data.browsingContext)
40 // These are two synchronous messages sent by the child.
41 // The browsingContext within the message data is either the one that has
42 // the active autocomplete popup or the top-level of the one that has
43 // the active autocomplete popup.
44 Services.ppmm.addMessageListener("AutoComplete:GetSelectedIndex", message => {
45 if (compareContext(message)) {
46 let actor = currentActor;
47 if (actor && actor.openedPopup) {
48 return actor.openedPopup.selectedIndex;
55 Services.ppmm.addMessageListener("AutoComplete:SelectBy", message => {
56 if (compareContext(message)) {
57 let actor = currentActor;
58 if (actor && actor.openedPopup) {
59 actor.openedPopup.selectBy(message.data.reverse, message.data.page);
64 // AutoCompleteResultView is an abstraction around a list of results.
65 // It implements enough of nsIAutoCompleteController and
66 // nsIAutoCompleteInput to make the richlistbox popup work. Since only
67 // one autocomplete popup should be open at a time, this is a singleton.
68 var AutoCompleteResultView = {
70 QueryInterface: ChromeUtils.generateQI([
71 "nsIAutoCompleteController",
72 "nsIAutoCompleteInput",
78 // The AutoCompleteParent currently showing results or null otherwise.
81 // nsIAutoCompleteController
83 return this.results.length;
87 return this.results[index].value;
90 getFinalCompleteValueAt(index) {
91 return this.results[index].value;
95 // Backwardly-used by richlist autocomplete - see getCommentAt.
96 // The label is used for secondary information.
97 return this.results[index].comment;
100 getCommentAt(index) {
101 // The richlist autocomplete popup uses comment for its main
102 // display of an item, which is why we're returning the label
104 return this.results[index].label;
108 return this.results[index].style;
112 return this.results[index].image;
115 handleEnter(aIsPopupSelection) {
116 if (this.currentActor) {
117 this.currentActor.handleEnter(aIsPopupSelection);
125 // nsIAutoCompleteInput
135 if (this.currentActor) {
136 this.currentActor.requestFocus();
140 // Internal JS-only API
142 this.currentActor = null;
146 setResults(actor, results) {
147 this.currentActor = actor;
148 this.results = results;
152 export class AutoCompleteParent extends JSWindowActorParent {
154 if (this.openedPopup) {
155 this.openedPopup.closePopup();
159 static getCurrentActor() {
163 static addPopupStateListener(listener) {
164 autoCompleteListeners.add(listener);
167 static removePopupStateListener(listener) {
168 autoCompleteListeners.delete(listener);
173 case "popupshowing": {
174 this.sendAsyncMessage("AutoComplete:PopupOpened", {});
178 case "popuphidden": {
179 let selectedIndex = this.openedPopup.selectedIndex;
180 let selectedRowComment =
182 ? AutoCompleteResultView.getCommentAt(selectedIndex)
184 let selectedRowStyle =
186 ? AutoCompleteResultView.getStyleAt(selectedIndex)
188 this.sendAsyncMessage("AutoComplete:PopupClosed", {
192 AutoCompleteResultView.clearResults();
193 // adjustHeight clears the height from the popup so that
194 // we don't have a big shrink effect if we closed with a
195 // large list, and then open on a small one.
196 this.openedPopup.adjustHeight();
197 this.openedPopup = null;
199 evt.target.removeEventListener("popuphidden", this);
200 evt.target.removeEventListener("popupshowing", this);
206 showPopupWithResults({ rect, dir, results }) {
207 if (!results.length || this.openedPopup) {
208 // We shouldn't ever be showing an empty popup, and if we
209 // already have a popup open, the old one needs to close before
210 // we consider opening a new one.
214 let browser = this.browsingContext.top.embedderElement;
215 let window = browser.ownerGlobal;
216 // Also check window top in case this is a sidebar.
218 Services.focus.activeWindow !== window.top &&
219 Services.focus.focusedWindow.top !== window.top
221 // We were sent a message from a window or tab that went into the
222 // background, so we'll ignore it for now.
226 // Non-empty result styles
227 let resultStyles = new Set(results.map(r => r.style).filter(r => !!r));
229 this.openedPopup = browser.autoCompletePopup;
230 // the layout varies according to different result type
231 this.openedPopup.setAttribute("resultstyles", [...resultStyles].join(" "));
232 this.openedPopup.hidden = false;
233 // don't allow the popup to become overly narrow
234 this.openedPopup.style.setProperty(
236 Math.max(100, rect.width) + "px"
238 this.openedPopup.style.direction = dir;
240 AutoCompleteResultView.setResults(this, results);
241 this.openedPopup.view = AutoCompleteResultView;
242 this.openedPopup.selectedIndex = -1;
244 // Reset fields that were set from the last time the search popup was open
245 this.openedPopup.mInput = AutoCompleteResultView;
246 // Temporarily increase the maxRows as we don't want to show
247 // the scrollbar in login or form autofill popups.
250 (resultStyles.has("autofill") || resultStyles.has("loginsFooter"))
252 this.openedPopup._normalMaxRows = this.openedPopup.maxRows;
253 this.openedPopup.mInput.maxRows = 10;
255 browser.constrainPopup(this.openedPopup);
256 this.openedPopup.addEventListener("popuphidden", this);
257 this.openedPopup.addEventListener("popupshowing", this);
258 this.openedPopup.openPopupAtScreenRect(
267 this.openedPopup.invalidate();
268 this._maybeRecordTelemetryEvents(results);
270 // This is a temporary solution. We should replace it with
271 // proper meta information about the popup once such field
272 // becomes available.
273 let isCreditCard = results.some(result =>
274 result?.comment?.includes("cc-number")
278 this.delayPopupInput();
283 * @param {object[]} results - Non-empty array of autocomplete results.
285 _maybeRecordTelemetryEvents(results) {
287 this.browsingContext.currentWindowGlobal.getActor("LoginManager");
288 actor.maybeRecordPasswordGenerationShownTelemetryEvent(results);
290 // Assume the result with the start time (loginsFooter) is last.
291 let lastResult = results[results.length - 1];
292 if (lastResult.style != "loginsFooter") {
296 // The comment field of `loginsFooter` results have many additional pieces of
297 // information for telemetry purposes. After bug 1555209, this information
298 // can be passed to the parent process outside of nsIAutoCompleteResult APIs
299 // so we won't need this hack.
300 let rawExtraData = JSON.parse(lastResult.comment).telemetryEventData;
301 if (!rawExtraData.searchStartTimeMS) {
302 throw new Error("Invalid autocomplete search start time");
305 if (rawExtraData.stringLength > 1) {
306 // To reduce event volume, only record for lengths 0 and 1.
311 Services.telemetry.msSystemNow() - rawExtraData.searchStartTimeMS;
312 delete rawExtraData.searchStartTimeMS;
314 // Add counts by result style to rawExtraData.
315 results.reduce((accumulated, r) => {
316 // Ignore learn more as it is only added after importable logins.
317 // Do not track generic items in the telemetry.
318 if (r.style === "importableLearnMore" || r.style === "generic") {
322 // Keys can be a maximum of 15 characters and values must be strings.
323 // Also treat both "loginWithOrigin" and "login" as "login" as extra_keys
325 let truncatedStyle = r.style.substring(
327 r.style === "loginWithOrigin" ? 5 : 15
329 accumulated[truncatedStyle] = (accumulated[truncatedStyle] || 0) + 1;
333 // Convert extra values to strings since recordEvent requires that.
334 let extraStrings = Object.fromEntries(
335 Object.entries(rawExtraData).map(([key, val]) => {
337 if (typeof val == "boolean") {
338 stringVal += val ? "1" : "0";
342 return [key, stringVal];
346 Services.telemetry.recordEvent(
350 // Convert to a string
356 invalidate(results) {
357 if (!this.openedPopup) {
361 if (!results.length) {
364 AutoCompleteResultView.setResults(this, results);
365 this.openedPopup.invalidate();
366 this._maybeRecordTelemetryEvents(results);
371 if (this.openedPopup) {
372 // Note that hidePopup() closes the popup immediately,
373 // so popuphiding or popuphidden events will be fired
374 // and handled during this call.
375 this.openedPopup.hidePopup();
379 async receiveMessage(message) {
380 let browser = this.browsingContext.top.embedderElement;
384 (!lazy.DELEGATE_AUTOCOMPLETE && !browser.autoCompletePopup)
386 // If there is no browser or popup, just make sure that the popup has been closed.
387 if (this.openedPopup) {
388 this.openedPopup.closePopup();
391 // Returning false to pacify ESLint, but this return value is
392 // ignored by the messaging infrastructure.
396 switch (message.name) {
397 case "AutoComplete:SetSelectedIndex": {
398 let { index } = message.data;
399 if (this.openedPopup) {
400 this.openedPopup.selectedIndex = index;
405 case "AutoComplete:MaybeOpenPopup": {
406 let { results, rect, dir, inputElementIdentifier, formOrigin } =
408 if (lazy.DELEGATE_AUTOCOMPLETE) {
409 lazy.GeckoViewAutocomplete.delegateSelection({
410 browsingContext: this.browsingContext,
412 inputElementIdentifier,
416 this.showPopupWithResults({ results, rect, dir });
417 this.notifyListeners();
422 case "AutoComplete:Invalidate": {
423 let { results } = message.data;
424 this.invalidate(results);
428 case "AutoComplete:ClosePopup": {
429 if (lazy.DELEGATE_AUTOCOMPLETE) {
430 lazy.GeckoViewAutocomplete.delegateDismiss();
437 case "AutoComplete:StartSearch": {
438 const { searchString, data } = message.data;
439 const result = await this.#startSearch(searchString, data);
443 // Returning false to pacify ESLint, but this return value is
444 // ignored by the messaging infrastructure.
448 // Imposes a brief period during which the popup will not respond to
449 // a click, so as to reduce the chances of a successful clickjacking
452 if (!this.openedPopup) {
455 const popupDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY);
457 // Mochitests set this to 0, and many will fail on integration
458 // if we make the popup items inactive, even briefly.
463 const items = Array.from(
464 this.openedPopup.getElementsByTagName("richlistitem")
466 items.forEach(item => (item.disabled = true));
469 () => items.forEach(item => (item.disabled = false)),
475 let window = this.browsingContext.top.embedderElement.ownerGlobal;
476 for (let listener of autoCompleteListeners) {
486 * Despite its name, this handleEnter is only called when the user clicks on
487 * one of the items in the popup since the popup is rendered in the parent process.
488 * The real controller's handleEnter is called directly in the content process
489 * for other methods of completing a selection (e.g. using the tab or enter
490 * keys) since the field with focus is in that process.
491 * @param {boolean} aIsPopupSelection
493 handleEnter(aIsPopupSelection) {
494 if (this.openedPopup) {
495 this.sendAsyncMessage("AutoComplete:HandleEnter", {
496 selectedIndex: this.openedPopup.selectedIndex,
497 isPopupSelection: aIsPopupSelection,
502 // This defines the supported autocomplete providers and the prioity to show the autocomplete
504 #AUTOCOMPLETE_PROVIDERS = ["FormAutofill", "LoginManager", "FormHistory"];
507 * Search across multiple module to gather autocomplete entries for a given search string.
509 * @param {string} searchString
510 * The input string used to query autocomplete entries across different
511 * autocomplete providers.
512 * @param {Array<Object>} providers
513 * An array of objects where each object has a `name` used to identify the actor
514 * name of the provider and `options` that are passed to the `searchAutoCompleteEntries`
515 * method of the actor.
516 * @returns {Array<Object>} An array of results objects with `name` of the provider and `entries`
517 * that are returned from the provider module's `searchAutoCompleteEntries` method.
519 async #startSearch(searchString, providers) {
520 for (const name of this.#AUTOCOMPLETE_PROVIDERS) {
521 const provider = providers.find(p => p.actorName == name);
525 const { actorName, options } = provider;
527 this.browsingContext.currentWindowGlobal.getActor(actorName);
528 const entries = await actor?.searchAutoCompleteEntries(
533 // We have not yet supported showing autocomplete entries from multiple providers,
535 return [{ actorName, ...entries }];
544 * Sends a message to the browser that is requesting the input
545 * that the open popup should be focused.
548 // Bug 1582722 - See the response in AutoCompleteChild.sys.mjs for why this
551 if (this.openedPopup) {
552 this.sendAsyncMessage("AutoComplete:Focus");