1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
6 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
10 ChromeUtils.defineESModuleGetters(lazy, {
11 AddressResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
12 AutofillTelemetry: "resource://gre/modules/shared/AutofillTelemetry.sys.mjs",
13 CreditCardResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
14 GenericAutocompleteItem: "resource://gre/modules/FillHelpers.sys.mjs",
15 InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs",
16 FieldDetail: "resource://gre/modules/shared/FieldScanner.sys.mjs",
17 FormAutofill: "resource://autofill/FormAutofill.sys.mjs",
18 FormAutofillContent: "resource://autofill/FormAutofillContent.sys.mjs",
20 "resource://gre/modules/shared/FormAutofillHandler.sys.mjs",
21 FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
22 FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
23 FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs",
24 FormStateManager: "resource://gre/modules/shared/FormStateManager.sys.mjs",
25 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
26 FORM_SUBMISSION_REASON: "resource://gre/actors/FormHandlerChild.sys.mjs",
29 XPCOMUtils.defineLazyPreferenceGetter(
31 "DELEGATE_AUTOCOMPLETE",
32 "toolkit.autocomplete.delegate",
37 * Handles content's interactions for the frame.
39 export class FormAutofillChild extends JSWindowActorChild {
40 // Flag to indicate whethere there is an ongoing autofilling process.
41 #autofillInProgress = false;
44 * Keep track of autofill handlers that are waiting for the parent process
45 * to send back the identified result.
47 #handlerWaitingForDetectedComplete = new Set();
52 this.log = lazy.FormAutofill.defineLogGetter(this, "FormAutofillChild");
55 this._hasDOMContentLoadedHandler = false;
57 this._hasRegisteredPageHide = new Set();
60 * @type {FormAutofillFieldDetailsManager} handling state management of current forms and handlers.
62 this._fieldDetailsManager = new lazy.FormStateManager(
63 this.onFilledModified.bind(this)
67 * Tracks whether the last form submission was triggered by a form submit event,
68 * if so we'll ignore the page navigation that follows
70 this.isFollowingSubmitEvent = false;
74 * After the parent process finishes classifying the fields, the parent process
75 * informs all the child process of the classified field result. The child process
76 * then sets the updated result to the corresponding AutofillHandler
78 * @param {Array<FieldDetail>} fieldDetails
79 * An array of the identified fields.
81 onFieldsDetectedComplete(fieldDetails) {
82 if (!fieldDetails.length) {
86 const handler = this._fieldDetailsManager.getFormHandlerByRootElementId(
87 fieldDetails[0].rootElementId
89 this.#handlerWaitingForDetectedComplete.delete(handler);
91 handler.setIdentifiedFieldDetails(fieldDetails);
93 let addressFields = [];
94 let creditcardFields = [];
96 handler.fieldDetails.forEach(fd => {
97 if (lazy.FormAutofillUtils.isAddressField(fd.fieldName)) {
98 addressFields.push(fd);
99 } else if (lazy.FormAutofillUtils.isCreditCardField(fd.fieldName)) {
100 creditcardFields.push(fd);
104 // Bug 1905040. This is only a temporarily workaround for now to skip marking address fields
105 // autocompletable whenever we detect an address field. We only mark address field when
106 // it is a valid address section (This is done in the parent)
107 const addressFieldSet = new Set(addressFields.map(fd => fd.fieldName));
109 addressFieldSet.size < lazy.FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD
114 // Inform the autocomplete controller these fields are autofillable
115 [...addressFields, ...creditcardFields].forEach(fieldDetail => {
116 this.#markAsAutofillField(fieldDetail);
118 if (fieldDetail.element == lazy.FormAutofillContent.focusedInput) {
119 this.showPopupIfEmpty(fieldDetail.element, fieldDetail.fieldName);
123 // Do not need to listen to form submission event because if the address fields do not contain
124 // 'street-address' or `address-linx`, we will not save the address.
126 creditcardFields.length ||
127 (addressFields.length &&
133 ].some(fieldName => addressFieldSet.has(fieldName)))
136 .getActor("FormHandler")
137 .registerFormSubmissionInterest(this, {
138 includesFormRemoval: lazy.FormAutofill.captureOnFormRemoval,
139 includesPageNavigation: lazy.FormAutofill.captureOnPageNavigation,
142 // TODO (Bug 1901486): Integrate pagehide to FormHandler.
143 if (!this._hasRegisteredPageHide.has(handler)) {
144 this.registerPageHide(handler);
145 this._hasRegisteredPageHide.add(true);
151 * Identifies elements that are in the associated form of the passed element.
153 * @param {Element} element
154 * The element to be identified.
156 * @returns {FormAutofillHandler}
157 * The autofill handler instance for the form that is associated with the
160 identifyFieldsWhenFocused(element) {
162 `identifyFieldsWhenFocused: ${element.ownerDocument.location?.hostname}`
165 const handler = this._fieldDetailsManager.getOrCreateFormHandler(element);
167 // If the child process is still waiting for the parent to send to
168 // `onFieldsDetectedComplete` message, bail out.
169 if (this.#handlerWaitingForDetectedComplete.has(handler)) {
173 // Bail out if there is nothing changed since last time we identified this element
174 // or there is no interested fields.
175 if (handler.hasIdentifiedFields() && !handler.updateFormIfNeeded(element)) {
176 // This is for testing purposes only. It sends a notification to indicate that the
177 // form has been identified and is ready to open the popup.
178 // If new fields are detected, the message will be sent to the parent
179 // once the parent finishes collecting information from sub-frames if they exist.
180 this.sendAsyncMessage("FormAutofill:FieldsIdentified");
183 handler.getFieldDetailByElement(element)?.fieldName ?? "";
184 this.showPopupIfEmpty(element, fieldName);
186 const includeIframe = this.browsingContext == this.browsingContext.top;
187 let detectedFields = lazy.FormAutofillHandler.collectFormFieldDetails(
192 // If none of the detected fields are credit card or address fields,
193 // there's no need to notify the parent because nothing will change.
195 !detectedFields.some(
197 lazy.FormAutofillUtils.isCreditCardField(fd.fieldName) ||
198 lazy.FormAutofillUtils.isAddressField(fd.fieldName)
201 handler.setIdentifiedFieldDetails(detectedFields);
205 this.sendAsyncMessage(
206 "FormAutofill:OnFieldsDetected",
207 detectedFields.map(field => field.toVanillaObject())
210 // Notify the parent about the newly identified fields because
211 // the autofill section information is maintained on the parent side.
212 this.#handlerWaitingForDetectedComplete.add(handler);
217 * This function is called by the parent when a field is detected in another
218 * frame. The parent uses this function to collect field information from frames
219 * that are part of the same form as the detected field.
221 * @param {string} focusedBCId
222 * The browsing context ID of the top-level iframe
223 * that contains the detected field.
224 * Note that this value is set only when the current frame is the top-level.
227 * Array of FieldDetail objects of identified fields (including iframes).
229 identifyFields(focusedBCId) {
230 const isTop = this.browsingContext == this.browsingContext.top;
234 // Find the focused iframe
235 element = BrowsingContext.get(focusedBCId).embedderElement;
237 // Ignore form as long as the frame is not the top-level, which means
238 // we can just pick any of the eligible elements to identify.
239 element = this.document.querySelector("input, select, iframe");
246 const handler = this._fieldDetailsManager.getOrCreateFormHandler(element);
248 // We don't have to call 'updateFormIfNeeded' like we do in
249 // 'identifyFieldsWhenFocused' because 'collectFormFieldDetails' doesn't use cached
251 const includeIframe = isTop;
252 const detectedFields = lazy.FormAutofillHandler.collectFormFieldDetails(
257 if (detectedFields.length) {
258 // This actor should receive `onFieldsDetectedComplete`message after
259 // `idenitfyFields` is called
260 this.#handlerWaitingForDetectedComplete.add(handler);
262 return detectedFields;
265 showPopupIfEmpty(element, fieldName) {
266 if (element?.value?.length !== 0) {
267 this.debug(`Not opening popup because field is not empty.`);
271 if (fieldName.startsWith("cc-") || AppConstants.platform === "android") {
272 lazy.FormAutofillContent.showPopup();
277 * We received a form-submission-detected event because
278 * the page was navigated.
281 if (!lazy.FormAutofill.captureOnPageNavigation) {
285 if (this.isFollowingSubmitEvent) {
286 // The next page navigation should be handled as form submission again
287 this.isFollowingSubmitEvent = false;
291 const formSubmissionReason = lazy.FORM_SUBMISSION_REASON.PAGE_NAVIGATION;
292 const weakIdentifiedForms =
293 this._fieldDetailsManager.getWeakIdentifiedForms();
295 for (const form of weakIdentifiedForms) {
296 // Disconnected forms are captured by the form removal heuristic
297 if (!form.isConnected) {
300 this.formSubmitted(form, formSubmissionReason);
305 * We received a form-submission-detected event because
306 * a form was removed from the DOM after a successful
309 * @param {Event} form form to be submitted
311 onFormRemoval(form) {
312 if (!lazy.FormAutofill.captureOnFormRemoval) {
316 const formSubmissionReason =
317 lazy.FORM_SUBMISSION_REASON.FORM_REMOVAL_AFTER_FETCH;
318 this.formSubmitted(form, formSubmissionReason);
319 this.manager.getActor("FormHandler").unregisterFormRemovalInterest(this);
322 registerPageHide(handler) {
323 // Check whether the section is in an <iframe>; and, if so,
324 // watch for the <iframe> to pagehide.
325 if (this.browsingContext != this.browsingContext.top) {
327 "Address/Credit card form is in an iframe -- watching for pagehide"
329 handler.window.addEventListener(
332 this.debug("Credit card subframe is pagehiding", handler.form);
334 const reason = lazy.FORM_SUBMISSION_REASON.IFRAME_PAGEHIDE;
335 this.formSubmitted(handler.form.rootElement, reason, handler);
336 this._hasRegisteredPageHide.delete(handler);
343 shouldIgnoreFormAutofillEvent(event) {
344 if (!event.isTrusted) {
349 !lazy.FormAutofill.isAutofillCreditCardsAvailable &&
350 !lazy.FormAutofill.isAutofillAddressesAvailable
355 const nodePrincipal = event.target.nodePrincipal;
356 return nodePrincipal.isSystemPrincipal || nodePrincipal.schemeIs("about");
361 !lazy.FormAutofill.isAutofillEnabled ||
362 this.shouldIgnoreFormAutofillEvent(evt)
367 if (!this.windowContext) {
368 // !this.windowContext must not be null, because we need the
369 // windowContext and/or docShell to (un)register form submission listeners
375 this.onFocusIn(evt.target);
378 case "form-submission-detected": {
379 const formElement = evt.detail.form;
380 const formSubmissionReason = evt.detail.reason;
381 this.onFormSubmission(formElement, formSubmissionReason);
386 throw new Error("Unexpected event type");
392 // When autofilling, we focus on the element before setting the autofill value
393 // (See FormAutofillHandler.fillFieldValue). We ignore the focus event for this
394 // case to avoid showing popup while autofilling.
396 !lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element) ||
397 this.#autofillInProgress
402 const doc = element.ownerDocument;
403 if (doc.readyState === "loading") {
404 // For auto-focused input, we might receive focus event before document becomes ready.
405 // When this happens, run field identification after receiving `DOMContentLoaded` event
406 if (!this._hasDOMContentLoadedHandler) {
407 this._hasDOMContentLoadedHandler = true;
408 doc.addEventListener(
410 () => this.onFocusIn(lazy.FormAutofillContent.focusedInput),
418 lazy.DELEGATE_AUTOCOMPLETE ||
419 !lazy.FormAutofillContent.savedFieldNames
421 this.debug("onFocusIn: savedFieldNames are not known yet");
423 // Init can be asynchronous because we don't need anything from the parent
425 this.sendAsyncMessage("FormAutofill:InitStorage");
428 this.identifyFieldsWhenFocused(element);
432 * Handle form-submission-detected event (dispatched by FormHandlerChild)
434 * Depending on the heuristic that detected the form submission,
435 * the form that is submitted is retrieved differently
437 * @param {HTMLFormElement} form that is being submitted
438 * @param {string} reason heuristic that detected the form submission
439 * (see FormHandlerChild.FORM_SUBMISSION_REASON)
441 onFormSubmission(form, reason) {
443 case lazy.FORM_SUBMISSION_REASON.PAGE_NAVIGATION:
444 this.onPageNavigation();
446 case lazy.FORM_SUBMISSION_REASON.FORM_SUBMIT_EVENT:
447 this.formSubmitted(form, reason);
449 case lazy.FORM_SUBMISSION_REASON.FORM_REMOVAL_AFTER_FETCH:
450 this.onFormRemoval(form);
455 async receiveMessage(message) {
456 switch (message.name) {
457 case "FormAutofill:FillFields": {
458 const { focusedId, ids, profile } = message.data;
459 const result = this.fillFields(focusedId, ids, profile);
461 // Return the autofilled result to the parent. The result
462 // is used by both tests and telemetry.
465 case "FormAutofill:ClearFilledFields": {
466 const { ids } = message.data;
467 const handler = this.#getHandlerByElementId(ids[0]);
468 handler?.clearFilledFields(ids);
471 case "FormAutofill:PreviewFields": {
472 const { ids, profile } = message.data;
473 const handler = this.#getHandlerByElementId(ids[0]);
476 handler?.previewFields(ids, profile);
478 handler?.clearPreviewedFields(ids);
482 case "FormAutofill:IdentifyFields": {
483 const { focusedBCId } = message.data ?? {};
484 return this.identifyFields(focusedBCId).map(fieldDetail =>
485 fieldDetail.toVanillaObject()
488 case "FormAutofill:GetFilledInfo": {
489 const { rootElementId } = message.data;
491 this._fieldDetailsManager.getFormHandlerByRootElementId(
494 return handler?.collectFormFilledData();
496 case "FormAutofill:InspectFields": {
497 const fieldDetails = this.inspectFields();
498 return fieldDetails.map(field => field.toVanillaObject());
500 case "FormAutofill:onFieldsDetectedComplete": {
501 const { fds } = message.data;
502 const fieldDetails = fds.map(fd =>
503 lazy.FieldDetail.fromVanillaObject(fd)
505 this.onFieldsDetectedComplete(fieldDetails);
513 * Handle a form submission and early return when:
514 * 1. In private browsing mode.
515 * 2. Could not map any autofill handler by form element.
516 * 3. Number of filled fields is less than autofill threshold
518 * @param {HTMLElement} formElement Root element which receives submit event.
519 * @param {string} formSubmissionReason Reason for invoking the form submission
520 * (see options for FORM_SUBMISSION_REASON in FormAutofillUtils))
521 * @param {object} handler FormAutofillHander, if known by caller
523 formSubmitted(formElement, formSubmissionReason, handler = undefined) {
524 this.debug(`Handling form submission - infered by ${formSubmissionReason}`);
526 lazy.AutofillTelemetry.recordFormSubmissionHeuristicCount(
530 if (!lazy.FormAutofill.isAutofillEnabled) {
531 this.debug("Form Autofill is disabled");
535 // The `domWin` truthiness test is used by unit tests to bypass this check.
536 const domWin = formElement.ownerGlobal;
541 if (lazy.PrivateBrowsingUtils.isContentWindowPrivate(domWin)) {
542 this.debug("Ignoring submission in a private window");
546 handler = handler || this._fieldDetailsManager.getFormHandler(formElement);
548 this.debug("Form element could not map to an existing handler");
552 const formFilledData = handler.collectFormFilledData();
553 if (!formFilledData) {
554 this.debug("Form handler could not obtain filled data");
558 // After a form submit event follows (most likely) a page navigation, so we set this flag
559 // to not handle the following one as form submission in order to avoid re-submitting the same form.
560 // Ideally, we should keep a record of the last submitted form details and based on that we
561 // should decide if we want to submit a form (bug 1895437)
562 this.isFollowingSubmitEvent = true;
564 this.sendAsyncMessage("FormAutofill:OnFormSubmit", {
565 rootElementId: handler.rootElementId,
571 * This is called by FormAutofillHandler
573 onFilledModified(fieldDetail, previousState, newState) {
574 const element = fieldDetail.element;
575 if (HTMLInputElement.isInstance(element)) {
576 // If the user manually blanks a credit card field, then
577 // we want the popup to be activated.
579 lazy.FormAutofillUtils.isCreditCardField(fieldDetail.fieldName) &&
582 lazy.FormAutofillContent.showPopup();
587 previousState == lazy.FormAutofillUtils.FIELD_STATES.AUTO_FILLED &&
588 newState == lazy.FormAutofillUtils.FIELD_STATES.NORMAL
590 this.sendAsyncMessage(
591 "FormAutofill:FieldFilledModified",
592 fieldDetail.elementId
597 async fillFields(focusedId, elementIds, profile) {
598 this.#autofillInProgress = true;
599 let result = new Map();
601 Services.obs.notifyObservers(null, "autofill-fill-starting");
602 const handler = this.#getHandlerByElementId(elementIds[0]);
603 handler.fillFields(focusedId, elementIds, profile);
605 // Return the autofilled result to the parent. The result
606 // is used by both tests and telemetry.
607 result = handler.collectFormFilledData();
609 Services.obs.notifyObservers(null, "autofill-fill-complete");
612 this.#autofillInProgress = false;
617 * Returns all the identified fields for this document.
618 * This function is only used by the autofill developer tool extension.
621 const isTop = this.browsingContext == this.browsingContext.top;
622 const elements = isTop
623 ? Array.from(this.document.querySelectorAll("input, select, iframe"))
624 : Array.from(this.document.querySelectorAll("input, select"));
626 // Unlike the case when users click on a field and we only run our heuristic
627 // on fields within the same form as the focused field, for inspection,
628 // we want to inspect all the forms in this page.
629 const roots = new Set();
630 let fieldDetails = [];
631 for (const element of elements) {
632 const formLike = lazy.FormLikeFactory.createFromField(element);
633 if (roots.has(formLike.rootElement)) {
636 roots.add(formLike.rootElement);
637 const handler = new lazy.FormAutofillHandler(formLike);
639 // Fields that cannot be recognized will still be reported with this API.
640 const includeIframe = isTop;
641 const fields = lazy.FormAutofillHandler.collectFormFieldDetails(
646 fieldDetails.push(...fields);
649 // The 'fieldDetails' array are grouped by form so might not follow their
650 // order in the DOM tree. We rebuild the array based on their order in
652 fieldDetails = elements
653 .map(element => fieldDetails.find(field => field.element == element))
654 .filter(field => !!field && field.element);
656 // Add a data attribute with a unique identifier to allow the inspector
657 // to link the element with its associated 'FieldDetail' information.
658 for (const fd of fieldDetails) {
659 const INSPECT_ATTRIBUTE = "data-moz-autofill-inspect-id";
660 fd.inspectId = fd.element.getAttribute(INSPECT_ATTRIBUTE);
666 #markAsAutofillField(fieldDetail) {
667 const element = fieldDetail.element;
669 // Since Form Autofill popup is only for input element, any non-Input
670 // element should be excluded here.
671 if (!HTMLInputElement.isInstance(element)) {
676 .getActor("AutoComplete")
677 ?.markAsAutoCompletableField(element, this);
681 return "FormAutofill";
685 * Get the search options when searching for autocomplete entries in the parent
687 * @param {HTMLInputElement} input - The input element to search for autocomplete entries
688 * @returns {object} the search options for the input
690 getAutoCompleteSearchOption(input) {
691 const fieldDetail = this._fieldDetailsManager
692 .getFormHandler(input)
693 ?.getFieldDetailByElement(input);
695 const scenarioName = lazy.FormScenarios.detect({ input }).signUpForm
696 ? "SignUpFormScenario"
699 fieldName: fieldDetail?.fieldName,
700 elementId: fieldDetail?.elementId,
706 * Ask the provider whether it might have autocomplete entry to show
707 * for the given input.
709 * @param {HTMLInputElement} input - The input element to search for autocomplete entries
710 * @returns {boolean} true if we shold search for autocomplete entries
712 shouldSearchForAutoComplete(input) {
713 const fieldDetail = this._fieldDetailsManager
714 .getFormHandler(input)
715 ?.getFieldDetailByElement(input);
719 const fieldName = fieldDetail.fieldName;
720 const isAddressField = lazy.FormAutofillUtils.isAddressField(fieldName);
721 const searchPermitted = isAddressField
722 ? lazy.FormAutofill.isAutofillAddressesEnabled
723 : lazy.FormAutofill.isAutofillCreditCardsEnabled;
724 // If the specified autofill feature is pref off, do not search
725 if (!searchPermitted) {
729 // No profile can fill the currently-focused input.
730 if (!lazy.FormAutofillContent.savedFieldNames.has(fieldName)) {
738 * Convert the search result to autocomplete results
740 * @param {string} searchString - The string to search for
741 * @param {HTMLInputElement} input - The input element to search for autocomplete entries
742 * @param {Array<object>} records - autocomplete records
743 * @returns {AutocompleteResult}
745 searchResultToAutoCompleteResult(searchString, input, records) {
750 const handler = this._fieldDetailsManager.getFormHandler(input);
751 const fieldDetail = handler?.getFieldDetailByElement(input);
756 const adaptedRecords = handler.getAdaptedProfiles(records.records);
757 const isSecure = lazy.InsecurePasswordUtils.isFormSecure(handler.form);
758 const isInputAutofilled =
759 input.autofillState == lazy.FormAutofillUtils.FIELD_STATES.AUTO_FILLED;
761 let AutocompleteResult;
763 // TODO: This should be calculated in the parent
764 // The field categories will be filled if the corresponding profile is
765 // used for autofill. We don't display this information for credit
766 // cards, so this is only calculated for address fields.
768 if (lazy.FormAutofillUtils.isAddressField(fieldDetail.fieldName)) {
769 AutocompleteResult = lazy.AddressResult;
770 fillCategories = adaptedRecords.map(profile => {
771 const fields = Object.keys(profile).filter(fieldName => {
772 const detail = handler.getFieldDetailByName(fieldName);
773 return detail ? handler.isFieldAutofillable(detail, profile) : false;
775 return lazy.FormAutofillUtils.getCategoriesFromFieldNames(fields);
778 AutocompleteResult = lazy.CreditCardResult;
781 const acResult = new AutocompleteResult(
784 records.allFieldNames,
787 { isSecure, isInputAutofilled }
790 const externalEntries = records.externalEntries;
792 acResult.externalEntries.push(
793 ...externalEntries.map(
795 new lazy.GenericAutocompleteItem(
799 entry.fillMessageName,
800 entry.fillMessageData
808 #getHandlerByElementId(elementId) {
809 const element = lazy.FormAutofillUtils.getElementByIdentifier(elementId);
810 return this._fieldDetailsManager.getFormHandler(element);