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/. */
4
5"use strict";
6
7var EXPORTED_SYMBOLS = ["AutoCompleteParent"];
8
9const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
10
11const { XPCOMUtils } = ChromeUtils.import(
12  "resource://gre/modules/XPCOMUtils.jsm"
13);
14
15XPCOMUtils.defineLazyModuleGetters(this, {
16  GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm",
17});
18
19XPCOMUtils.defineLazyPreferenceGetter(
20  this,
21  "DELEGATE_AUTOCOMPLETE",
22  "toolkit.autocomplete.delegate",
23  false
24);
25
26ChromeUtils.defineModuleGetter(
27  this,
28  "setTimeout",
29  "resource://gre/modules/Timer.jsm"
30);
31
32const PREF_SECURITY_DELAY = "security.notification_enable_delay";
33
34// Stores the browser and actor that has the active popup, used by formfill
35let currentBrowserWeakRef = null;
36let currentActor = null;
37
38let autoCompleteListeners = new Set();
39
40function compareContext(message) {
41  if (
42    !currentActor ||
43    (currentActor.browsingContext != message.data.browsingContext &&
44      currentActor.browsingContext.top != message.data.browsingContext)
45  ) {
46    return false;
47  }
48
49  return true;
50}
51
52// These are two synchronous messages sent by the child.
53// The browsingContext within the message data is either the one that has
54// the active autocomplete popup or the top-level of the one that has
55// the active autocomplete popup.
56Services.ppmm.addMessageListener(
57  "FormAutoComplete:GetSelectedIndex",
58  message => {
59    if (compareContext(message)) {
60      let actor = currentActor;
61      if (actor && actor.openedPopup) {
62        return actor.openedPopup.selectedIndex;
63      }
64    }
65
66    return -1;
67  }
68);
69
70Services.ppmm.addMessageListener("FormAutoComplete:SelectBy", message => {
71  if (compareContext(message)) {
72    let actor = currentActor;
73    if (actor && actor.openedPopup) {
74      actor.openedPopup.selectBy(message.data.reverse, message.data.page);
75    }
76  }
77});
78
79// AutoCompleteResultView is an abstraction around a list of results.
80// It implements enough of nsIAutoCompleteController and
81// nsIAutoCompleteInput to make the richlistbox popup work. Since only
82// one autocomplete popup should be open at a time, this is a singleton.
83var AutoCompleteResultView = {
84  // nsISupports
85  QueryInterface: ChromeUtils.generateQI([
86    "nsIAutoCompleteController",
87    "nsIAutoCompleteInput",
88  ]),
89
90  // Private variables
91  results: [],
92
93  // The AutoCompleteParent currently showing results or null otherwise.
94  currentActor: null,
95
96  // nsIAutoCompleteController
97  get matchCount() {
98    return this.results.length;
99  },
100
101  getValueAt(index) {
102    return this.results[index].value;
103  },
104
105  getFinalCompleteValueAt(index) {
106    return this.results[index].value;
107  },
108
109  getLabelAt(index) {
110    // Backwardly-used by richlist autocomplete - see getCommentAt.
111    // The label is used for secondary information.
112    return this.results[index].comment;
113  },
114
115  getCommentAt(index) {
116    // The richlist autocomplete popup uses comment for its main
117    // display of an item, which is why we're returning the label
118    // here instead.
119    return this.results[index].label;
120  },
121
122  getStyleAt(index) {
123    return this.results[index].style;
124  },
125
126  getImageAt(index) {
127    return this.results[index].image;
128  },
129
130  handleEnter(aIsPopupSelection) {
131    if (this.currentActor) {
132      this.currentActor.handleEnter(aIsPopupSelection);
133    }
134  },
135
136  stopSearch() {},
137
138  searchString: "",
139
140  // nsIAutoCompleteInput
141  get controller() {
142    return this;
143  },
144
145  get popup() {
146    return null;
147  },
148
149  _focus() {
150    if (this.currentActor) {
151      this.currentActor.requestFocus();
152    }
153  },
154
155  // Internal JS-only API
156  clearResults() {
157    this.currentActor = null;
158    this.results = [];
159  },
160
161  setResults(actor, results) {
162    this.currentActor = actor;
163    this.results = results;
164  },
165};
166
167class AutoCompleteParent extends JSWindowActorParent {
168  didDestroy() {
169    if (this.openedPopup) {
170      this.openedPopup.closePopup();
171    }
172  }
173
174  static getCurrentActor() {
175    return currentActor;
176  }
177
178  static getCurrentBrowser() {
179    return currentBrowserWeakRef ? currentBrowserWeakRef.get() : null;
180  }
181
182  static addPopupStateListener(listener) {
183    autoCompleteListeners.add(listener);
184  }
185
186  static removePopupStateListener(listener) {
187    autoCompleteListeners.delete(listener);
188  }
189
190  handleEvent(evt) {
191    switch (evt.type) {
192      case "popupshowing": {
193        this.sendAsyncMessage("FormAutoComplete:PopupOpened", {});
194        break;
195      }
196
197      case "popuphidden": {
198        let selectedIndex = this.openedPopup.selectedIndex;
199        let selectedRowComment =
200          selectedIndex != -1
201            ? AutoCompleteResultView.getCommentAt(selectedIndex)
202            : "";
203        let selectedRowStyle =
204          selectedIndex != -1
205            ? AutoCompleteResultView.getStyleAt(selectedIndex)
206            : "";
207        this.sendAsyncMessage("FormAutoComplete:PopupClosed", {
208          selectedRowComment,
209          selectedRowStyle,
210        });
211        AutoCompleteResultView.clearResults();
212        // adjustHeight clears the height from the popup so that
213        // we don't have a big shrink effect if we closed with a
214        // large list, and then open on a small one.
215        this.openedPopup.adjustHeight();
216        this.openedPopup = null;
217        currentBrowserWeakRef = null;
218        currentActor = null;
219        evt.target.removeEventListener("popuphidden", this);
220        evt.target.removeEventListener("popupshowing", this);
221        break;
222      }
223    }
224  }
225
226  showPopupWithResults({ rect, dir, results }) {
227    if (!results.length || this.openedPopup) {
228      // We shouldn't ever be showing an empty popup, and if we
229      // already have a popup open, the old one needs to close before
230      // we consider opening a new one.
231      return;
232    }
233
234    let browser = this.browsingContext.top.embedderElement;
235    let window = browser.ownerGlobal;
236    // Also check window top in case this is a sidebar.
237    if (
238      Services.focus.activeWindow !== window.top &&
239      Services.focus.focusedWindow.top !== window.top
240    ) {
241      // We were sent a message from a window or tab that went into the
242      // background, so we'll ignore it for now.
243      return;
244    }
245
246    // Non-empty result styles
247    let resultStyles = new Set(results.map(r => r.style).filter(r => !!r));
248    currentBrowserWeakRef = Cu.getWeakReference(browser);
249    currentActor = this;
250    this.openedPopup = browser.autoCompletePopup;
251    // the layout varies according to different result type
252    this.openedPopup.setAttribute("resultstyles", [...resultStyles].join(" "));
253    this.openedPopup.hidden = false;
254    // don't allow the popup to become overly narrow
255    this.openedPopup.style.setProperty(
256      "--panel-width",
257      Math.max(100, rect.width) + "px"
258    );
259    this.openedPopup.style.direction = dir;
260
261    AutoCompleteResultView.setResults(this, results);
262    this.openedPopup.view = AutoCompleteResultView;
263    this.openedPopup.selectedIndex = -1;
264
265    // Reset fields that were set from the last time the search popup was open
266    this.openedPopup.mInput = AutoCompleteResultView;
267    // Temporarily increase the maxRows as we don't want to show
268    // the scrollbar in login or form autofill popups.
269    if (
270      resultStyles.size &&
271      (resultStyles.has("autofill-profile") || resultStyles.has("loginsFooter"))
272    ) {
273      this.openedPopup._normalMaxRows = this.openedPopup.maxRows;
274      this.openedPopup.mInput.maxRows = 10;
275    }
276    this.openedPopup.addEventListener("popuphidden", this);
277    this.openedPopup.addEventListener("popupshowing", this);
278    this.openedPopup.openPopupAtScreenRect(
279      "after_start",
280      rect.left,
281      rect.top,
282      rect.width,
283      rect.height,
284      false,
285      false
286    );
287    this.openedPopup.invalidate();
288    this._maybeRecordTelemetryEvents(results);
289
290    // This is a temporary solution. We should replace it with
291    // proper meta information about the popup once such field
292    // becomes available.
293    let isCreditCard = results.some(result =>
294      result?.comment?.includes("cc-number")
295    );
296
297    if (isCreditCard) {
298      this.delayPopupInput();
299    }
300  }
301
302  /**
303   * @param {object[]} results - Non-empty array of autocomplete results.
304   */
305  _maybeRecordTelemetryEvents(results) {
306    let actor = this.browsingContext.currentWindowGlobal.getActor(
307      "LoginManager"
308    );
309    actor.maybeRecordPasswordGenerationShownTelemetryEvent(results);
310
311    // Assume the result with the start time (loginsFooter) is last.
312    let lastResult = results[results.length - 1];
313    if (lastResult.style != "loginsFooter") {
314      return;
315    }
316
317    // The comment field of `loginsFooter` results have many additional pieces of
318    // information for telemetry purposes. After bug 1555209, this information
319    // can be passed to the parent process outside of nsIAutoCompleteResult APIs
320    // so we won't need this hack.
321    let rawExtraData = JSON.parse(lastResult.comment);
322    if (!rawExtraData.searchStartTimeMS) {
323      throw new Error("Invalid autocomplete search start time");
324    }
325
326    if (rawExtraData.stringLength > 1) {
327      // To reduce event volume, only record for lengths 0 and 1.
328      return;
329    }
330
331    let duration =
332      Services.telemetry.msSystemNow() - rawExtraData.searchStartTimeMS;
333    delete rawExtraData.searchStartTimeMS;
334
335    delete rawExtraData.formHostname;
336
337    // Add counts by result style to rawExtraData.
338    results.reduce((accumulated, r) => {
339      // Ignore learn more as it is only added after importable logins.
340      if (r.style === "importableLearnMore") {
341        return accumulated;
342      }
343
344      // Keys can be a maximum of 15 characters and values must be strings.
345      // Also treat both "loginWithOrigin" and "login" as "login" as extra_keys
346      // is limited to 10.
347      let truncatedStyle = r.style.substring(
348        0,
349        r.style === "loginWithOrigin" ? 5 : 15
350      );
351      accumulated[truncatedStyle] = (accumulated[truncatedStyle] || 0) + 1;
352      return accumulated;
353    }, rawExtraData);
354
355    // Convert extra values to strings since recordEvent requires that.
356    let extraStrings = Object.fromEntries(
357      Object.entries(rawExtraData).map(([key, val]) => {
358        let stringVal = "";
359        if (typeof val == "boolean") {
360          stringVal += val ? "1" : "0";
361        } else {
362          stringVal += val;
363        }
364        return [key, stringVal];
365      })
366    );
367
368    Services.telemetry.recordEvent(
369      "form_autocomplete",
370      "show",
371      "logins",
372      // Convert to a string
373      duration + "",
374      extraStrings
375    );
376  }
377
378  invalidate(results) {
379    if (!this.openedPopup) {
380      return;
381    }
382
383    if (!results.length) {
384      this.closePopup();
385    } else {
386      AutoCompleteResultView.setResults(this, results);
387      this.openedPopup.invalidate();
388      this._maybeRecordTelemetryEvents(results);
389    }
390  }
391
392  closePopup() {
393    if (this.openedPopup) {
394      // Note that hidePopup() closes the popup immediately,
395      // so popuphiding or popuphidden events will be fired
396      // and handled during this call.
397      this.openedPopup.hidePopup();
398    }
399  }
400
401  receiveMessage(message) {
402    let browser = this.browsingContext.top.embedderElement;
403
404    if (!browser || (!DELEGATE_AUTOCOMPLETE && !browser.autoCompletePopup)) {
405      // If there is no browser or popup, just make sure that the popup has been closed.
406      if (this.openedPopup) {
407        this.openedPopup.closePopup();
408      }
409
410      // Returning false to pacify ESLint, but this return value is
411      // ignored by the messaging infrastructure.
412      return false;
413    }
414
415    switch (message.name) {
416      case "FormAutoComplete:SetSelectedIndex": {
417        let { index } = message.data;
418        if (this.openedPopup) {
419          this.openedPopup.selectedIndex = index;
420        }
421        break;
422      }
423
424      case "FormAutoComplete:MaybeOpenPopup": {
425        let {
426          results,
427          rect,
428          dir,
429          inputElementIdentifier,
430          formOrigin,
431        } = message.data;
432        if (DELEGATE_AUTOCOMPLETE) {
433          GeckoViewAutocomplete.delegateSelection({
434            browsingContext: this.browsingContext,
435            options: results,
436            inputElementIdentifier,
437            formOrigin,
438          });
439        } else {
440          this.showPopupWithResults({ results, rect, dir });
441          this.notifyListeners();
442        }
443        break;
444      }
445
446      case "FormAutoComplete:Invalidate": {
447        let { results } = message.data;
448        this.invalidate(results);
449        break;
450      }
451
452      case "FormAutoComplete:ClosePopup": {
453        this.closePopup();
454        break;
455      }
456
457      case "FormAutoComplete:Disconnect": {
458        // The controller stopped controlling the current input, so clear
459        // any cached data.  This is necessary cause otherwise we'd clear data
460        // only when starting a new search, but the next input could not support
461        // autocomplete and it would end up inheriting the existing data.
462        AutoCompleteResultView.clearResults();
463        break;
464      }
465    }
466    // Returning false to pacify ESLint, but this return value is
467    // ignored by the messaging infrastructure.
468    return false;
469  }
470
471  // Imposes a brief period during which the popup will not respond to
472  // a click, so as to reduce the chances of a successful clickjacking
473  // attempt
474  delayPopupInput() {
475    if (!this.openedPopup) {
476      return;
477    }
478    const popupDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY);
479
480    // Mochitests set this to 0, and many will fail on integration
481    // if we make the popup items inactive, even briefly.
482    if (!popupDelay) {
483      return;
484    }
485
486    const items = Array.from(
487      this.openedPopup.getElementsByTagName("richlistitem")
488    );
489    items.forEach(item => (item.disabled = true));
490
491    setTimeout(
492      () => items.forEach(item => (item.disabled = false)),
493      popupDelay
494    );
495  }
496
497  notifyListeners() {
498    let window = this.browsingContext.top.embedderElement.ownerGlobal;
499    for (let listener of autoCompleteListeners) {
500      try {
501        listener(window);
502      } catch (ex) {
503        Cu.reportError(ex);
504      }
505    }
506  }
507
508  /**
509   * Despite its name, this handleEnter is only called when the user clicks on
510   * one of the items in the popup since the popup is rendered in the parent process.
511   * The real controller's handleEnter is called directly in the content process
512   * for other methods of completing a selection (e.g. using the tab or enter
513   * keys) since the field with focus is in that process.
514   * @param {boolean} aIsPopupSelection
515   */
516  handleEnter(aIsPopupSelection) {
517    if (this.openedPopup) {
518      this.sendAsyncMessage("FormAutoComplete:HandleEnter", {
519        selectedIndex: this.openedPopup.selectedIndex,
520        isPopupSelection: aIsPopupSelection,
521      });
522    }
523  }
524
525  stopSearch() {}
526
527  /**
528   * Sends a message to the browser that is requesting the input
529   * that the open popup should be focused.
530   */
531  requestFocus() {
532    // Bug 1582722 - See the response in AutoCompleteChild.jsm for why this disabled.
533    /*
534    if (this.openedPopup) {
535      this.sendAsyncMessage("FormAutoComplete:Focus");
536    }
537    */
538  }
539}
540