/* 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, { FormAutofillAddressSection: "resource://gre/modules/shared/FormAutofillSection.sys.mjs", FormAutofillCreditCardSection: "resource://gre/modules/shared/FormAutofillSection.sys.mjs", FormAutofillHeuristics: "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs", FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", FormSection: "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs", }); const { FIELD_STATES } = FormAutofillUtils; /** * Handles profile autofill for a DOM Form element. */ export class FormAutofillHandler { // The window to which this form belongs window = null; // A WindowUtils reference of which Window the form belongs winUtils = null; // DOM Form element to which this object is attached form = null; // An array of section that are found in this form sections = []; // The section contains the focused input #focusedSection = null; // Caches the element to section mapping #cachedSectionByElement = new WeakMap(); // Keeps track of filled state for all identified elements #filledStateByElement = new WeakMap(); /** * 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; /** * 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} onFormSubmitted Function that can be invoked * to simulate form submission. Function is passed * four arguments: (1) a FormLike for the form being * submitted, (2) the reason for infering the form * submission (3) the corresponding Window, and (4) * the responsible FormAutofillHandler. * @param {Function} onAutofillCallback Function that can be invoked * when we want to suggest autofill on a form. */ constructor(form, onFormSubmitted = () => {}, onAutofillCallback = () => {}) { this._updateForm(form); this.window = this.form.rootElement.ownerGlobal; this.winUtils = this.window.windowUtils; // Enum for form autofill MANUALLY_MANAGED_STATES values this.FIELD_STATE_ENUM = { // not themed [FIELD_STATES.NORMAL]: null, // highlighted [FIELD_STATES.AUTO_FILLED]: "autofill", // highlighted && grey color text [FIELD_STATES.PREVIEW]: "-moz-autofill-preview", }; /** * This function is used if the form handler (or one of its sections) * determines that it needs to act as if the form had been submitted. */ this.onFormSubmitted = formSubmissionReason => { onFormSubmitted(this.form, formSubmissionReason, this.window, this); }; this.onAutofillCallback = onAutofillCallback; ChromeUtils.defineLazyGetter(this, "log", () => FormAutofill.defineLogGetter(this, "FormAutofillHandler") ); } handleEvent(event) { switch (event.type) { case "input": { if (!event.isTrusted) { return; } const target = event.target; const targetFieldDetail = this.getFieldDetailByElement(target); const isCreditCardField = FormAutofillUtils.isCreditCardField( targetFieldDetail.fieldName ); // If the user manually blanks a credit card field, then // we want the popup to be activated. if ( !HTMLSelectElement.isInstance(target) && isCreditCardField && target.value === "" ) { this.onAutofillCallback(); } if (this.getFilledStateByElement(target) == FIELD_STATES.NORMAL) { return; } this.changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL); const section = this.getSectionByElement(targetFieldDetail.element); section?.clearFilled(targetFieldDetail); } } } set focusedInput(element) { const section = this.getSectionByElement(element); if (!section) { return; } this.#focusedSection = section; this.#focusedSection.focusedInput = element; } getSectionByElement(element) { const section = this.#cachedSectionByElement.get(element) ?? this.sections.find(s => s.getFieldDetailByElement(element)); if (!section) { return null; } this.#cachedSectionByElement.set(element, section); return section; } getFieldDetailByElement(element) { for (const section of this.sections) { const detail = section.getFieldDetailByElement(element); if (detail) { return detail; } } return null; } get activeSection() { return this.#focusedSection; } /** * 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.FormLikeFactory.createFromField(element); } return _formLike; }; const currentForm = element.form ?? 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; } /** * 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; this.sections = []; this.#cachedSectionByElement = new WeakMap(); } /** * Set fieldDetails from the form about fields that can be autofilled. * * @returns {Array} The valid address and credit card details. */ collectFormFields(ignoreInvalid = true) { const sections = lazy.FormAutofillHeuristics.getFormInfo(this.form); const allValidDetails = []; for (const section of sections) { // We don't support csc field, so remove csc fields from section const fieldDetails = section.fieldDetails.filter( f => !["cc-csc"].includes(f.fieldName) ); if (!fieldDetails.length) { continue; } let autofillableSection; if (section.type == lazy.FormSection.ADDRESS) { autofillableSection = new lazy.FormAutofillAddressSection( fieldDetails, this ); } else { autofillableSection = new lazy.FormAutofillCreditCardSection( fieldDetails, this ); } // Do not include section that is either disabled or invalid. // We only include invalid section for testing purpose. if ( !autofillableSection.isEnabled() || (ignoreInvalid && !autofillableSection.isValidSection()) ) { continue; } this.sections.push(autofillableSection); allValidDetails.push(...autofillableSection.fieldDetails); } this.fieldDetails = allValidDetails; return allValidDetails; } #hasFilledSection() { return this.sections.some(section => section.isFilled()); } getFilledStateByElement(element) { return this.#filledStateByElement.get(element); } /** * Change the state of a field to correspond with different presentations. * * @param {object} fieldDetail * A fieldDetail of which its element is about to update the state. * @param {string} nextState * Used to determine the next state */ changeFieldState(fieldDetail, nextState) { const element = fieldDetail.element; if (!element) { this.log.warn( fieldDetail.fieldName, "is unreachable while changing state" ); return; } if (!(nextState in this.FIELD_STATE_ENUM)) { this.log.warn( fieldDetail.fieldName, "is trying to change to an invalid state" ); return; } if (this.#filledStateByElement.get(element) == nextState) { return; } let nextStateValue = null; for (const [state, mmStateValue] of Object.entries(this.FIELD_STATE_ENUM)) { // The NORMAL state is simply the absence of other manually // managed states so we never need to add or remove it. if (!mmStateValue) { continue; } if (state == nextState) { nextStateValue = mmStateValue; } else { this.winUtils.removeManuallyManagedState(element, mmStateValue); } } if (nextStateValue) { this.winUtils.addManuallyManagedState(element, nextStateValue); } if (nextState == FIELD_STATES.AUTO_FILLED) { element.addEventListener("input", this, { mozSystemGroup: true }); } this.#filledStateByElement.set(element, nextState); } /** * Processes form fields that can be autofilled, and populates them with the * profile provided by backend. * * @param {object} profile * A profile to be filled in. */ async autofillFormFields(profile) { const noFilledSectionsPreviously = !this.#hasFilledSection(); await this.activeSection.autofillFields(profile); const onChangeHandler = e => { if (!e.isTrusted) { return; } if (e.type == "reset") { this.sections.map(section => section.resetFieldStates()); } // Unregister listeners once no field is in AUTO_FILLED state. if (!this.#hasFilledSection()) { this.form.rootElement.removeEventListener("input", onChangeHandler, { mozSystemGroup: true, }); this.form.rootElement.removeEventListener("reset", onChangeHandler, { mozSystemGroup: true, }); } }; if (noFilledSectionsPreviously) { // Handle the highlight style resetting caused by user's correction afterward. this.log.debug("register change handler for filled form:", this.form); this.form.rootElement.addEventListener("input", onChangeHandler, { mozSystemGroup: true, }); this.form.rootElement.addEventListener("reset", onChangeHandler, { mozSystemGroup: true, }); } } /** * Collect the filled sections within submitted form and convert all the valid * field data into multiple records. * * @returns {object} records * {Array.} records.address * {Array.} records.creditCard */ createRecords() { const records = { address: [], creditCard: [], }; for (const section of this.sections) { const secRecord = section.createRecord(); if (!secRecord) { continue; } if (section instanceof lazy.FormAutofillAddressSection) { records.address.push(secRecord); } else if (section instanceof lazy.FormAutofillCreditCardSection) { records.creditCard.push(secRecord); } else { throw new Error("Unknown section type"); } } return records; } }