/* 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 { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AddressParser: "resource://gre/modules/shared/AddressParser.sys.mjs", AutofillFormFactory: "resource://gre/modules/shared/AutofillFormFactory.sys.mjs", CreditCard: "resource://gre/modules/CreditCard.sys.mjs", FieldDetail: "resource://gre/modules/shared/FieldScanner.sys.mjs", FormAutofillHeuristics: "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs", FormAutofillNameUtils: "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs", LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs", clearTimeout: "resource://gre/modules/Timer.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", }); const { FIELD_STATES } = FormAutofillUtils; export const FORM_CHANGE_REASON = { NODES_ADDED: "nodes-added", NODES_REMOVED: "nodes-removed", SELECT_OPTIONS_CHANGED: "select-options-changed", ELEMENT_INVISIBLE: "visible-element-became-invisible", ELEMENT_VISIBLE: "invisible-element-became-visible", }; /** * Handles profile autofill for a DOM Form element. */ export class FormAutofillHandler { // The window to which this form belongs window = null; // DOM Form element to which this object is attached form = null; // Keeps track of filled state for all identified elements #filledStateByElement = new WeakMap(); // An object that caches the current selected option, keyed by element. #matchingSelectOption = null; /** * Array of collected data about relevant form fields. Each item is an object * storing the identifying details of the field and a reference to the * originally associated element from the form. * * The "section", "addressType", "contactType", and "fieldName" values are * used to identify the exact field when the serializable data is received * from the backend. There cannot be multiple fields which have * the same exact combination of these values. * * A direct reference to the associated element cannot be sent to the user * interface because processing may be done in the parent process. */ #fieldDetails = null; /** * Flags if the MutationObserver (this.#formMutationObserver) that is observing * node additions/removals for the root element has been set up */ #isObservingFormMutations = false; #formMutationObserver = null; #visibilityObserver = null; #visibilityStateObserverByElement = new WeakMap(); /** * * fillOnFormChangeData.isWithinDynamicFormChangeThreshold: * Flags if a "form-change" event is received within the timeout threshold * (see FormAutofill.fillOnDynamicFormChangeTimeout), that we set * in order to consider newly detected fields for filling. * fillOnFormChangeData.previouslyUsedProfile * The previously used profile from the latest autocompletion. * fillOnFormChangeData.previouslyFocusedId * The previously focused element id from the latest autocompletion * * This is used for any following form changes and is cleared after a time threshold * set by FormAutofill.fillOnDynamicFormChangeTimeout. */ #fillOnFormChangeData = new Map(); /** * Caching the refill timeout id to cancel it once we know that we're about to fill * on form change, because this sets up another refill timeout. */ #refillTimeoutId = null; /** * Flag to indicate whethere there is an ongoing autofilling/clearing process. */ #isAutofillInProgress = false; /** * Initialize the form from `FormLike` object to handle the section or form * operations. * * @param {FormLike} form Form that need to be auto filled * @param {Function} onFilledModifiedCallback Function that can be invoked * when we want to suggest autofill on a form. */ constructor(form, onFilledModifiedCallback = () => {}) { this._updateForm(form); this.window = this.form.rootElement.ownerGlobal; this.onFilledModifiedCallback = onFilledModifiedCallback; // The identifier generated via ContentDOMReference for the root element. this.rootElementId = FormAutofillUtils.getElementIdentifier( form.rootElement ); ChromeUtils.defineLazyGetter(this, "log", () => FormAutofill.defineLogGetter(this, "FormAutofillHandler") ); } get fillOnFormChangeData() { return this.#fillOnFormChangeData; } clearFillOnFormChangeData() { this.#fillOnFormChangeData = new Map(); this.#fillOnFormChangeData.isWithinDynamicFormChangeThreshold = false; } /** * Retrieves the 'fieldDetails' property, ensuring it has been initialized by * `setIdentifiedFieldDetails`. Throws an error if accessed before initialization. * * This is because 'fieldDetail'' contains information that need to be computed * in the parent side first. * * @throws {Error} If `setIdentifiedFieldDetails` has not been called. * @returns {Array} * The list of autofillable field details for this form. */ get fieldDetails() { if (!this.#fieldDetails) { throw new Error( `Should only use 'fieldDetails' after 'setIdentifiedFieldDetails' is called` ); } return this.#fieldDetails; } /** * Sets the list of 'FieldDetail' objects for autofillable fields within the form. * * @param {Array} fieldDetails * An array of field details that has been computed on the parent side. * This method should be called before accessing `fieldDetails`. */ setIdentifiedFieldDetails(fieldDetails) { this.#fieldDetails = fieldDetails; } /** * Determines whether 'setIdentifiedFieldDetails' has been called and the * `fieldDetails` have been initialized. * * @returns {boolean} * True if 'fieldDetails' has been initialized; otherwise, False. */ hasIdentifiedFields() { return !!this.#fieldDetails; } get isAutofillInProgress() { return this.#isAutofillInProgress; } handleEvent(event) { switch (event.type) { case "input": { if (!event.isTrusted || this.isAutofillInProgress) { return; } // This uses the #filledStateByElement map instead of // autofillState as the state has already been cleared by the time // the input event fires. const fieldDetail = this.getFieldDetailByElement(event.target); const previousState = this.getFilledStateByElement(event.target); const newState = FIELD_STATES.NORMAL; if (previousState != newState) { this.changeFieldState(fieldDetail, newState); } this.onFilledModifiedCallback?.(fieldDetail, previousState, newState); } } } getFieldDetailByName(fieldName) { return this.fieldDetails.find(detail => detail.fieldName == fieldName); } getFieldDetailByElement(element) { return this.fieldDetails.find(detail => detail.element == element); } getFieldDetailByElementId(elementId) { return this.fieldDetails.find(detail => detail.elementId == elementId); } /** * Only use this API within handleEvent */ getFilledStateByElement(element) { return this.#filledStateByElement.get(element); } #clearVisibilityObserver() { this.#visibilityObserver.disconnect(); this.#visibilityObserver = null; this.#visibilityStateObserverByElement = new WeakMap(); } /** * Check the form is necessary to be updated. This function should be able to * detect any changes including all control elements in the form. * * @param {HTMLElement} element The element supposed to be in the form. * @returns {boolean} FormAutofillHandler.form is updated or not. */ updateFormIfNeeded(element) { // When the following condition happens, FormAutofillHandler.form should be // updated: // * The count of form controls is changed. // * When the element can not be found in the current form. // // However, we should improve the function to detect the element changes. // e.g. a tel field is changed from type="hidden" to type="tel". let _formLike; const getFormLike = () => { if (!_formLike) { _formLike = lazy.AutofillFormFactory.createFromField(element); } return _formLike; }; const currentForm = getFormLike(); if (currentForm.elements.length != this.form.elements.length) { this.log.debug("The count of form elements is changed."); this._updateForm(getFormLike()); return true; } if (!this.form.elements.includes(element)) { this.log.debug("The element can not be found in the current form."); this._updateForm(getFormLike()); return true; } return false; } updateFormByElement(element) { const formLike = lazy.AutofillFormFactory.createFromField(element); this._updateForm(formLike); } /** * Update the form with a new FormLike, and the related fields should be * updated or clear to ensure the data consistency. * * @param {FormLike} form a new FormLike to replace the original one. */ _updateForm(form) { this.form = form; this.#fieldDetails = null; } /** * Collect , fields. * * @returns {Array} * An array containing eligible fields for autofill, also * including iframe. */ static collectFormFieldDetails( formLike, includeIframe, ignoreInvisibleInput = true ) { const fieldDetails = lazy.FormAutofillHeuristics.getFormInfo(formLike, ignoreInvisibleInput) ?? []; // 'FormLike' only contains & element to its selected option or the first // option if there is none selected. const selected = [...element.options].find(option => option.hasAttribute("selected") ); value = selected ? selected.value : element.options[0].value; } FormAutofillHandler.fillFieldValue(element, value); this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); } } this.focusPreviouslyFocusedElement(focusedId); this.#isAutofillInProgress = false; } focusPreviouslyFocusedElement(focusedId) { let focusedElement = FormAutofillUtils.getElementByIdentifier(focusedId); if (FormAutofillUtils.focusOnAutofill && focusedElement) { focusedElement.focus({ preventScroll: true }); } } /** * Return the record that is keyed by element id and value is the normalized value * done by computeFillingValue * * @returns {object} An object keyed by element id, and the value is * an object that includes the following properties: * filledState: The autofill state of the element * filledvalue: The value of the element */ collectFormFilledData() { const filledData = new Map(); for (const fieldDetail of this.fieldDetails) { const element = fieldDetail.element; filledData.set(fieldDetail.elementId, { filledState: element.autofillState, filledValue: this.computeFillingValue(fieldDetail), }); } return filledData; } isFieldAutofillable(fieldDetail, profile) { if (FormAutofillUtils.isTextControl(fieldDetail.element)) { return !!profile[fieldDetail.fieldName]; } return !!this.matchSelectOptions(fieldDetail, profile); } }