/* 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; } else { filledValuesByElement.set(element, element.value); } FormAutofillHandler.fillFieldValue(element, value); this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); } } this.focusPreviouslyFocusedElement(focusedId); this.#isAutofillInProgress = false; this.reassignValuesIfModified(filledValuesByElement, true); } 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); } } /** Apply some transformations to the fields on the profile based * on the fields that appear in the form. The original values are * saved and used if the transformer is used again. */ class ProfileTransformer { // The FormAutofillHandler #handler = null; // A profile for adjusting credit card and address related values. #profile = null; constructor(handler, profile) { this.#handler = handler; this.#profile = profile; } // Get the original unmodified value of a field if it exists. getField(fieldName) { if (this.#profile._original) { let value = this.#profile._original[fieldName]; if (value) { return value; } } return this.#profile[fieldName]; } // Get the modified value of a field if it exists, or the // original value. getUpdatedField(fieldName) { return this.#profile[fieldName]; } // Modify a field's value, but store the original value for // use later. setField(fieldName, value) { let originalValue = this.#profile[fieldName]; if (originalValue) { if (!this.#profile._original) { this.#profile._original = {}; } if (!this.#profile._original[fieldName]) { this.#profile._original[fieldName] = originalValue; } } this.#profile[fieldName] = value; } // Delete the modified value of a field, but leave the stored // original value. deleteField(fieldName) { delete this.#profile[fieldName]; } getFieldDetailByName(fieldName) { return this.#handler.getFieldDetailByName(fieldName); } applyTransformers() { this.#addressTransformer(); this.#telTransformer(); this.#creditCardExpiryDateTransformer(); this.#creditCardExpMonthAndYearTransformer(); this.#creditCardNameTransformer(); this.#adaptFieldMaxLength(); } // This function mostly uses getUpdatedField as it relies on the modified // values of fields from the previous functions. #adaptFieldMaxLength() { for (let key in this.#profile) { let detail = this.getFieldDetailByName(key); if (!detail || detail.part) { continue; } let element = detail.element; if (!element) { continue; } let maxLength = element.maxLength; if ( maxLength === undefined || maxLength < 0 || this.getUpdatedField(key).toString().length <= maxLength ) { continue; } if (maxLength) { switch (typeof this.getUpdatedField(key)) { case "string": // If this is an expiration field and our previous // adaptations haven't resulted in a string that is // short enough to satisfy the field length, and the // field is constrained to a length of 4 or 5, then we // assume it is intended to hold an expiration of the // form "MMYY" or "MM/YY". if (key == "cc-exp" && (maxLength == 4 || maxLength == 5)) { const month2Digits = ( "0" + this.getField("cc-exp-month").toString() ).slice(-2); const year2Digits = this.getField("cc-exp-year") .toString() .slice(-2); const separator = maxLength == 5 ? "/" : ""; this.setField(key, `${month2Digits}${separator}${year2Digits}`); } else if (key == "cc-number") { // We want to show the last four digits of credit card so that // the masked credit card previews correctly and appears correctly // in the autocomplete menu let value = this.getField(key); this.setField(key, value.substr(value.length - maxLength)); } else { this.setField( key, this.getUpdatedField(key).substr(0, maxLength) ); } break; case "number": // There's no way to truncate a number smaller than a // single digit. if (maxLength < 1) { maxLength = 1; } // The only numbers we store are expiration month/year, // and if they truncate, we want the final digits, not // the initial ones. this.setField( key, this.getUpdatedField(key) % Math.pow(10, maxLength) ); break; default: } } else { // This code only seems to run when maxlength = 0, an edge case which // hardly seems worth handling. this.deleteField(key); this.deleteField(`${key}-formatted`); } } } /** * Handles credit card expiry date transformation when * the expiry date exists in a cc-exp field. */ #creditCardExpiryDateTransformer() { if (!this.getField("cc-exp")) { return; } const element = this.getFieldDetailByName("cc-exp")?.element; if (!element) { return; } function updateExpiry(_string, _month, _year) { // Bug 1687681: This is a short term fix to other locales having // different characters to represent year. // - FR locales may use "A" to represent year. // - DE locales may use "J" to represent year. // - PL locales may use "R" to represent year. // This approach will not scale well and should be investigated in a follow up bug. const monthChars = "m"; const yearChars = "yy|aa|jj|rr"; const expiryDateFormatRegex = (firstChars, secondChars) => new RegExp( "(?:\\b|^)((?:[" + firstChars + "]{2}){1,2})\\s*([\\-/])\\s*((?:[" + secondChars + "]{2}){1,2})(?:\\b|$)", "i" ); // If the month first check finds a result, where placeholder is "mm - yyyy", // the result will be structured as such: ["mm - yyyy", "mm", "-", "yyyy"] let result = expiryDateFormatRegex(monthChars, yearChars).exec(_string); if (result) { return ( _month.padStart(result[1].length, "0") + result[2] + _year.substr(-1 * result[3].length) ); } // If the year first check finds a result, where placeholder is "yyyy mm", // the result will be structured as such: ["yyyy mm", "yyyy", " ", "mm"] result = expiryDateFormatRegex(yearChars, monthChars).exec(_string); if (result) { return ( _year.substr(-1 * result[1].length) + result[2] + _month.padStart(result[3].length, "0") ); } return null; } let newExpiryString = null; const month = this.getField("cc-exp-month").toString(); const year = this.getField("cc-exp-year").toString(); if (element.localName == "input") { // Use the placeholder or label to determine the expiry string format. const possibleExpiryStrings = []; if (element.placeholder) { possibleExpiryStrings.push(element.placeholder); } const labels = lazy.LabelUtils.findLabelElements(element); if (labels) { // Not consider multiple lable for now. possibleExpiryStrings.push(element.labels[0]?.textContent); } if (element.previousElementSibling?.localName == "label") { possibleExpiryStrings.push(element.previousElementSibling.textContent); } possibleExpiryStrings.some(string => { newExpiryString = updateExpiry(string, month, year); return !!newExpiryString; }); } // Bug 1688576: Change YYYY-MM to MM/YYYY since MM/YYYY is the // preferred presentation format for credit card expiry dates. this.setField( "cc-exp", newExpiryString ?? `${month.padStart(2, "0")}/${year}` ); } /** * Handles credit card expiry date transformation when the expiry date exists in * the separate cc-exp-month and cc-exp-year fields */ #creditCardExpMonthAndYearTransformer() { const getInputElementByField = (field, self) => { if (!field) { return null; } const detail = self.getFieldDetailByName(field); if (!detail) { return null; } const element = detail.element; return element.localName === "input" ? element : null; }; const month = getInputElementByField("cc-exp-month", this); if (month) { // Transform the expiry month to MM since this is a common format needed for filling. this.setField( "cc-exp-month-formatted", this.getField("cc-exp-month")?.toString().padStart(2, "0") ); } const year = getInputElementByField("cc-exp-year", this); // If the expiration year element is an input, // then we examine any placeholder to see if we should format the expiration year // as a zero padded string in order to autofill correctly. if (year) { const placeholder = year.placeholder; // Checks for 'YY'|'AA'|'JJ'|'RR' placeholder and converts the year to a two digit string using the last two digits. const result = /\b(yy|aa|jj|rr)\b/i.test(placeholder); if (result) { this.setField( "cc-exp-year-formatted", this.getField("cc-exp-year")?.toString().substring(2) ); } } } /** * Handles credit card name transformation when the name exists in * the separate cc-given-name, cc-middle-name, and cc-family name fields */ #creditCardNameTransformer() { const name = this.getField("cc-name"); if (!name) { return; } const given = this.getFieldDetailByName("cc-given-name"); const middle = this.getFieldDetailByName("cc-middle-name"); const family = this.getFieldDetailByName("cc-family-name"); if (given || middle || family) { const nameParts = lazy.FormAutofillNameUtils.splitName(name); if (given && nameParts.given) { this.setField("cc-given-name", nameParts.given); } if (middle && nameParts.middle) { this.setField("cc-middle-name", nameParts.middle); } if (family && nameParts.family) { this.setField("cc-family-name", nameParts.family); } } } #addressTransformer() { let streetAddress = this.getField("street-address"); if (streetAddress) { // "-moz-street-address-one-line" is used by the labels in // ProfileAutoCompleteResult. this.setField( "-moz-street-address-one-line", FormAutofillUtils.toOneLineAddress(streetAddress) ); let streetAddressDetail = this.getFieldDetailByName("street-address"); if ( streetAddressDetail && FormAutofillUtils.isTextControl(streetAddressDetail.element) ) { this.setField( "street-address", this.getField("-moz-street-address-one-line") ); } let waitForConcat = []; for (let f of ["address-line3", "address-line2", "address-line1"]) { waitForConcat.unshift(this.getField(f)); if (this.getFieldDetailByName(f)) { if (waitForConcat.length > 1) { this.setField(f, FormAutofillUtils.toOneLineAddress(waitForConcat)); } waitForConcat = []; } } } // If a house number field exists, split the address up into house number // and street name. if (this.getFieldDetailByName("address-housenumber")) { let address = lazy.AddressParser.parseStreetAddress( this.getField("street-address") ); if (address) { this.setField("address-housenumber", address.street_number); let field = this.getFieldDetailByName("address-line1") ? "address-line1" : "street-address"; this.setField(field, address.street_name); } } } /** * Replace tel with tel-national if tel violates the input element's * restriction. */ #telTransformer() { let tel = this.getField("tel"); let telNational = this.getField("tel-national"); if (!tel || !telNational) { return; } let detail = this.getFieldDetailByName("tel"); if (!detail) { return; } let element = detail.element; let _pattern; let testPattern = str => { if (!_pattern) { // The pattern has to match the entire value. _pattern = new RegExp("^(?:" + element.pattern + ")$", "u"); } return _pattern.test(str); }; if (element.pattern) { if (testPattern(tel)) { return; } } else if (element.maxLength) { if (detail.reason == "autocomplete" && tel.length <= element.maxLength) { return; } } if (detail.reason != "autocomplete") { // Since we only target people living in US and using en-US websites in // MVP, it makes more sense to fill `tel-national` instead of `tel` // if the field is identified by heuristics and no other clues to // determine which one is better. // TODO: [Bug 1407545] This should be improved once more countries are // supported. this.setField("tel", telNational); } else if (element.pattern) { if (testPattern(telNational)) { this.setField("tel", telNational); } } else if (element.maxLength) { if (telNational.length <= element.maxLength) { this.setField("tel", telNational); } } } }