Source code

Revision control

Copy as Markdown

Other Tools

/* 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.defineLazyPreferenceGetter(
lazy,
"DELEGATE_AUTOCOMPLETE",
"toolkit.autocomplete.delegate",
false
);
ChromeUtils.defineESModuleGetters(lazy, {
});
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("AutoComplete:GetSelectedIndex", message => {
if (compareContext(message)) {
let actor = currentActor;
if (actor && actor.openedPopup) {
return actor.openedPopup.selectedIndex;
}
}
return -1;
});
Services.ppmm.addMessageListener("AutoComplete: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("AutoComplete: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("AutoComplete: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") || 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();
}
}
async 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 "AutoComplete:SetSelectedIndex": {
let { index } = message.data;
if (this.openedPopup) {
this.openedPopup.selectedIndex = index;
}
break;
}
case "AutoComplete: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 "AutoComplete:Invalidate": {
let { results } = message.data;
this.invalidate(results);
break;
}
case "AutoComplete:ClosePopup": {
if (lazy.DELEGATE_AUTOCOMPLETE) {
lazy.GeckoViewAutocomplete.delegateDismiss();
break;
}
this.closePopup();
break;
}
case "AutoComplete:StartSearch": {
const { searchString, data } = message.data;
const result = await this.#startSearch(searchString, data);
return result;
}
}
// 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("AutoComplete:HandleEnter", {
selectedIndex: this.openedPopup.selectedIndex,
isPopupSelection: aIsPopupSelection,
});
}
}
// This defines the supported autocomplete providers and the prioity to show the autocomplete
// entry.
#AUTOCOMPLETE_PROVIDERS = ["FormAutofill", "LoginManager", "FormHistory"];
/**
* Search across multiple module to gather autocomplete entries for a given search string.
*
* @param {string} searchString
* The input string used to query autocomplete entries across different
* autocomplete providers.
* @param {Array<Object>} providers
* An array of objects where each object has a `name` used to identify the actor
* name of the provider and `options` that are passed to the `searchAutoCompleteEntries`
* method of the actor.
* @returns {Array<Object>} An array of results objects with `name` of the provider and `entries`
* that are returned from the provider module's `searchAutoCompleteEntries` method.
*/
async #startSearch(searchString, providers) {
for (const name of this.#AUTOCOMPLETE_PROVIDERS) {
const provider = providers.find(p => p.actorName == name);
if (!provider) {
continue;
}
const { actorName, options } = provider;
const actor =
this.browsingContext.currentWindowGlobal.getActor(actorName);
const entries = await actor?.searchAutoCompleteEntries(
searchString,
options
);
// We have not yet supported showing autocomplete entries from multiple providers,
if (entries) {
return [{ actorName, ...entries }];
}
}
return [];
}
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.sys.mjs for why this
// disabled.
/*
if (this.openedPopup) {
this.sendAsyncMessage("AutoComplete:Focus");
}
*/
}
}