summaryrefslogtreecommitdiffstats
path: root/toolkit/actors/AutoCompleteParent.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/actors/AutoCompleteParent.sys.mjs')
-rw-r--r--toolkit/actors/AutoCompleteParent.sys.mjs516
1 files changed, 516 insertions, 0 deletions
diff --git a/toolkit/actors/AutoCompleteParent.sys.mjs b/toolkit/actors/AutoCompleteParent.sys.mjs
new file mode 100644
index 0000000000..82ebf22dcf
--- /dev/null
+++ b/toolkit/actors/AutoCompleteParent.sys.mjs
@@ -0,0 +1,516 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "DELEGATE_AUTOCOMPLETE",
+ "toolkit.autocomplete.delegate",
+ false
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const PREF_SECURITY_DELAY = "security.notification_enable_delay";
+
+// Stores the actor that has the active popup, used by formfill
+let currentActor = null;
+
+let autoCompleteListeners = new Set();
+
+function compareContext(message) {
+ if (
+ !currentActor ||
+ (currentActor.browsingContext != message.data.browsingContext &&
+ currentActor.browsingContext.top != message.data.browsingContext)
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+// These are two synchronous messages sent by the child.
+// The browsingContext within the message data is either the one that has
+// the active autocomplete popup or the top-level of the one that has
+// the active autocomplete popup.
+Services.ppmm.addMessageListener(
+ "FormAutoComplete:GetSelectedIndex",
+ message => {
+ if (compareContext(message)) {
+ let actor = currentActor;
+ if (actor && actor.openedPopup) {
+ return actor.openedPopup.selectedIndex;
+ }
+ }
+
+ return -1;
+ }
+);
+
+Services.ppmm.addMessageListener("FormAutoComplete:SelectBy", message => {
+ if (compareContext(message)) {
+ let actor = currentActor;
+ if (actor && actor.openedPopup) {
+ actor.openedPopup.selectBy(message.data.reverse, message.data.page);
+ }
+ }
+});
+
+// AutoCompleteResultView is an abstraction around a list of results.
+// It implements enough of nsIAutoCompleteController and
+// nsIAutoCompleteInput to make the richlistbox popup work. Since only
+// one autocomplete popup should be open at a time, this is a singleton.
+var AutoCompleteResultView = {
+ // nsISupports
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIAutoCompleteController",
+ "nsIAutoCompleteInput",
+ ]),
+
+ // Private variables
+ results: [],
+
+ // The AutoCompleteParent currently showing results or null otherwise.
+ currentActor: null,
+
+ // nsIAutoCompleteController
+ get matchCount() {
+ return this.results.length;
+ },
+
+ getValueAt(index) {
+ return this.results[index].value;
+ },
+
+ getFinalCompleteValueAt(index) {
+ return this.results[index].value;
+ },
+
+ getLabelAt(index) {
+ // Backwardly-used by richlist autocomplete - see getCommentAt.
+ // The label is used for secondary information.
+ return this.results[index].comment;
+ },
+
+ getCommentAt(index) {
+ // The richlist autocomplete popup uses comment for its main
+ // display of an item, which is why we're returning the label
+ // here instead.
+ return this.results[index].label;
+ },
+
+ getStyleAt(index) {
+ return this.results[index].style;
+ },
+
+ getImageAt(index) {
+ return this.results[index].image;
+ },
+
+ handleEnter(aIsPopupSelection) {
+ if (this.currentActor) {
+ this.currentActor.handleEnter(aIsPopupSelection);
+ }
+ },
+
+ stopSearch() {},
+
+ searchString: "",
+
+ // nsIAutoCompleteInput
+ get controller() {
+ return this;
+ },
+
+ get popup() {
+ return null;
+ },
+
+ _focus() {
+ if (this.currentActor) {
+ this.currentActor.requestFocus();
+ }
+ },
+
+ // Internal JS-only API
+ clearResults() {
+ this.currentActor = null;
+ this.results = [];
+ },
+
+ setResults(actor, results) {
+ this.currentActor = actor;
+ this.results = results;
+ },
+};
+
+export class AutoCompleteParent extends JSWindowActorParent {
+ didDestroy() {
+ if (this.openedPopup) {
+ this.openedPopup.closePopup();
+ }
+ }
+
+ static getCurrentActor() {
+ return currentActor;
+ }
+
+ static addPopupStateListener(listener) {
+ autoCompleteListeners.add(listener);
+ }
+
+ static removePopupStateListener(listener) {
+ autoCompleteListeners.delete(listener);
+ }
+
+ handleEvent(evt) {
+ switch (evt.type) {
+ case "popupshowing": {
+ this.sendAsyncMessage("FormAutoComplete:PopupOpened", {});
+ break;
+ }
+
+ case "popuphidden": {
+ let selectedIndex = this.openedPopup.selectedIndex;
+ let selectedRowComment =
+ selectedIndex != -1
+ ? AutoCompleteResultView.getCommentAt(selectedIndex)
+ : "";
+ let selectedRowStyle =
+ selectedIndex != -1
+ ? AutoCompleteResultView.getStyleAt(selectedIndex)
+ : "";
+ this.sendAsyncMessage("FormAutoComplete:PopupClosed", {
+ selectedRowComment,
+ selectedRowStyle,
+ });
+ AutoCompleteResultView.clearResults();
+ // adjustHeight clears the height from the popup so that
+ // we don't have a big shrink effect if we closed with a
+ // large list, and then open on a small one.
+ this.openedPopup.adjustHeight();
+ this.openedPopup = null;
+ currentActor = null;
+ evt.target.removeEventListener("popuphidden", this);
+ evt.target.removeEventListener("popupshowing", this);
+ break;
+ }
+ }
+ }
+
+ showPopupWithResults({ rect, dir, results }) {
+ if (!results.length || this.openedPopup) {
+ // We shouldn't ever be showing an empty popup, and if we
+ // already have a popup open, the old one needs to close before
+ // we consider opening a new one.
+ return;
+ }
+
+ let browser = this.browsingContext.top.embedderElement;
+ let window = browser.ownerGlobal;
+ // Also check window top in case this is a sidebar.
+ if (
+ Services.focus.activeWindow !== window.top &&
+ Services.focus.focusedWindow.top !== window.top
+ ) {
+ // We were sent a message from a window or tab that went into the
+ // background, so we'll ignore it for now.
+ return;
+ }
+
+ // Non-empty result styles
+ let resultStyles = new Set(results.map(r => r.style).filter(r => !!r));
+ currentActor = this;
+ this.openedPopup = browser.autoCompletePopup;
+ // the layout varies according to different result type
+ this.openedPopup.setAttribute("resultstyles", [...resultStyles].join(" "));
+ this.openedPopup.hidden = false;
+ // don't allow the popup to become overly narrow
+ this.openedPopup.style.setProperty(
+ "--panel-width",
+ Math.max(100, rect.width) + "px"
+ );
+ this.openedPopup.style.direction = dir;
+
+ AutoCompleteResultView.setResults(this, results);
+ this.openedPopup.view = AutoCompleteResultView;
+ this.openedPopup.selectedIndex = -1;
+
+ // Reset fields that were set from the last time the search popup was open
+ this.openedPopup.mInput = AutoCompleteResultView;
+ // Temporarily increase the maxRows as we don't want to show
+ // the scrollbar in login or form autofill popups.
+ if (
+ resultStyles.size &&
+ (resultStyles.has("autofill-profile") || resultStyles.has("loginsFooter"))
+ ) {
+ this.openedPopup._normalMaxRows = this.openedPopup.maxRows;
+ this.openedPopup.mInput.maxRows = 10;
+ }
+ browser.constrainPopup(this.openedPopup);
+ this.openedPopup.addEventListener("popuphidden", this);
+ this.openedPopup.addEventListener("popupshowing", this);
+ this.openedPopup.openPopupAtScreenRect(
+ "after_start",
+ rect.left,
+ rect.top,
+ rect.width,
+ rect.height,
+ false,
+ false
+ );
+ this.openedPopup.invalidate();
+ this._maybeRecordTelemetryEvents(results);
+
+ // This is a temporary solution. We should replace it with
+ // proper meta information about the popup once such field
+ // becomes available.
+ let isCreditCard = results.some(result =>
+ result?.comment?.includes("cc-number")
+ );
+
+ if (isCreditCard) {
+ this.delayPopupInput();
+ }
+ }
+
+ /**
+ * @param {object[]} results - Non-empty array of autocomplete results.
+ */
+ _maybeRecordTelemetryEvents(results) {
+ let actor =
+ this.browsingContext.currentWindowGlobal.getActor("LoginManager");
+ actor.maybeRecordPasswordGenerationShownTelemetryEvent(results);
+
+ // Assume the result with the start time (loginsFooter) is last.
+ let lastResult = results[results.length - 1];
+ if (lastResult.style != "loginsFooter") {
+ return;
+ }
+
+ // The comment field of `loginsFooter` results have many additional pieces of
+ // information for telemetry purposes. After bug 1555209, this information
+ // can be passed to the parent process outside of nsIAutoCompleteResult APIs
+ // so we won't need this hack.
+ let rawExtraData = JSON.parse(lastResult.comment).telemetryEventData;
+ if (!rawExtraData.searchStartTimeMS) {
+ throw new Error("Invalid autocomplete search start time");
+ }
+
+ if (rawExtraData.stringLength > 1) {
+ // To reduce event volume, only record for lengths 0 and 1.
+ return;
+ }
+
+ let duration =
+ Services.telemetry.msSystemNow() - rawExtraData.searchStartTimeMS;
+ delete rawExtraData.searchStartTimeMS;
+
+ // Add counts by result style to rawExtraData.
+ results.reduce((accumulated, r) => {
+ // Ignore learn more as it is only added after importable logins.
+ // Do not track generic items in the telemetry.
+ if (r.style === "importableLearnMore" || r.style === "generic") {
+ return accumulated;
+ }
+
+ // Keys can be a maximum of 15 characters and values must be strings.
+ // Also treat both "loginWithOrigin" and "login" as "login" as extra_keys
+ // is limited to 10.
+ let truncatedStyle = r.style.substring(
+ 0,
+ r.style === "loginWithOrigin" ? 5 : 15
+ );
+ accumulated[truncatedStyle] = (accumulated[truncatedStyle] || 0) + 1;
+ return accumulated;
+ }, rawExtraData);
+
+ // Convert extra values to strings since recordEvent requires that.
+ let extraStrings = Object.fromEntries(
+ Object.entries(rawExtraData).map(([key, val]) => {
+ let stringVal = "";
+ if (typeof val == "boolean") {
+ stringVal += val ? "1" : "0";
+ } else {
+ stringVal += val;
+ }
+ return [key, stringVal];
+ })
+ );
+
+ Services.telemetry.recordEvent(
+ "form_autocomplete",
+ "show",
+ "logins",
+ // Convert to a string
+ duration + "",
+ extraStrings
+ );
+ }
+
+ invalidate(results) {
+ if (!this.openedPopup) {
+ return;
+ }
+
+ if (!results.length) {
+ this.closePopup();
+ } else {
+ AutoCompleteResultView.setResults(this, results);
+ this.openedPopup.invalidate();
+ this._maybeRecordTelemetryEvents(results);
+ }
+ }
+
+ closePopup() {
+ if (this.openedPopup) {
+ // Note that hidePopup() closes the popup immediately,
+ // so popuphiding or popuphidden events will be fired
+ // and handled during this call.
+ this.openedPopup.hidePopup();
+ }
+ }
+
+ receiveMessage(message) {
+ let browser = this.browsingContext.top.embedderElement;
+
+ if (
+ !browser ||
+ (!lazy.DELEGATE_AUTOCOMPLETE && !browser.autoCompletePopup)
+ ) {
+ // If there is no browser or popup, just make sure that the popup has been closed.
+ if (this.openedPopup) {
+ this.openedPopup.closePopup();
+ }
+
+ // Returning false to pacify ESLint, but this return value is
+ // ignored by the messaging infrastructure.
+ return false;
+ }
+
+ switch (message.name) {
+ case "FormAutoComplete:SetSelectedIndex": {
+ let { index } = message.data;
+ if (this.openedPopup) {
+ this.openedPopup.selectedIndex = index;
+ }
+ break;
+ }
+
+ case "FormAutoComplete:MaybeOpenPopup": {
+ let { results, rect, dir, inputElementIdentifier, formOrigin } =
+ message.data;
+ if (lazy.DELEGATE_AUTOCOMPLETE) {
+ lazy.GeckoViewAutocomplete.delegateSelection({
+ browsingContext: this.browsingContext,
+ options: results,
+ inputElementIdentifier,
+ formOrigin,
+ });
+ } else {
+ this.showPopupWithResults({ results, rect, dir });
+ this.notifyListeners();
+ }
+ break;
+ }
+
+ case "FormAutoComplete:Invalidate": {
+ let { results } = message.data;
+ this.invalidate(results);
+ break;
+ }
+
+ case "FormAutoComplete:ClosePopup": {
+ if (lazy.DELEGATE_AUTOCOMPLETE) {
+ lazy.GeckoViewAutocomplete.delegateDismiss();
+ break;
+ }
+ this.closePopup();
+ break;
+ }
+ }
+ // Returning false to pacify ESLint, but this return value is
+ // ignored by the messaging infrastructure.
+ return false;
+ }
+
+ // Imposes a brief period during which the popup will not respond to
+ // a click, so as to reduce the chances of a successful clickjacking
+ // attempt
+ delayPopupInput() {
+ if (!this.openedPopup) {
+ return;
+ }
+ const popupDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY);
+
+ // Mochitests set this to 0, and many will fail on integration
+ // if we make the popup items inactive, even briefly.
+ if (!popupDelay) {
+ return;
+ }
+
+ const items = Array.from(
+ this.openedPopup.getElementsByTagName("richlistitem")
+ );
+ items.forEach(item => (item.disabled = true));
+
+ lazy.setTimeout(
+ () => items.forEach(item => (item.disabled = false)),
+ popupDelay
+ );
+ }
+
+ notifyListeners() {
+ let window = this.browsingContext.top.embedderElement.ownerGlobal;
+ for (let listener of autoCompleteListeners) {
+ try {
+ listener(window);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ /**
+ * Despite its name, this handleEnter is only called when the user clicks on
+ * one of the items in the popup since the popup is rendered in the parent process.
+ * The real controller's handleEnter is called directly in the content process
+ * for other methods of completing a selection (e.g. using the tab or enter
+ * keys) since the field with focus is in that process.
+ * @param {boolean} aIsPopupSelection
+ */
+ handleEnter(aIsPopupSelection) {
+ if (this.openedPopup) {
+ this.sendAsyncMessage("FormAutoComplete:HandleEnter", {
+ selectedIndex: this.openedPopup.selectedIndex,
+ isPopupSelection: aIsPopupSelection,
+ });
+ }
+ }
+
+ stopSearch() {}
+
+ /**
+ * Sends a message to the browser that is requesting the input
+ * that the open popup should be focused.
+ */
+ requestFocus() {
+ // Bug 1582722 - See the response in AutoCompleteChild.jsm for why this disabled.
+ /*
+ if (this.openedPopup) {
+ this.sendAsyncMessage("FormAutoComplete:Focus");
+ }
+ */
+ }
+}