/* 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 { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs", LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs", }); export class GeckoViewAutoFillChild extends GeckoViewActorChild { constructor() { super(); this._autofillElements = undefined; this._autofillInfos = undefined; } // eslint-disable-next-line complexity handleEvent(aEvent) { debug`handleEvent: ${aEvent.type}`; switch (aEvent.type) { case "DOMFormHasPassword": { this.addElement( lazy.FormLikeFactory.createFromForm(aEvent.composedTarget) ); break; } case "DOMInputPasswordAdded": { const input = aEvent.composedTarget; if (!input.form) { this.addElement(lazy.FormLikeFactory.createFromField(input)); } break; } case "focusin": { const element = aEvent.composedTarget; if (!this.contentWindow.HTMLInputElement.isInstance(element)) { break; } GeckoViewUtils.waitForPanZoomState(this.contentWindow).finally(() => { if (Cu.isDeadWrapper(element)) { // Focus element is removed or document is navigated to new page. return; } const focusedElement = Services.focus.focusedElement || element.ownerDocument?.activeElement; if (element == focusedElement) { this.onFocus(focusedElement); } }); break; } case "focusout": { if ( this.contentWindow.HTMLInputElement.isInstance(aEvent.composedTarget) ) { this.onFocus(null); } break; } case "pagehide": { if (aEvent.target === this.document) { this.clearElements(this.browsingContext); } break; } case "pageshow": { if (aEvent.target === this.document) { this.scanDocument(this.document); } break; } case "PasswordManager:ShowDoorhanger": { const { form: formLike } = aEvent.detail; this.commitAutofill(formLike); break; } } } /** * Process an auto-fillable form and send the relevant details of the form * to Java. Multiple calls within a short time period for the same form are * coalesced, so that, e.g., if multiple inputs are added to a form in * succession, we will only perform one processing pass. Note that for inputs * without forms, FormLikeFactory treats the document as the "form", but * there is no difference in how we process them. * * @param aFormLike A FormLike object produced by FormLikeFactory. */ async addElement(aFormLike) { debug`Adding auto-fill ${aFormLike.rootElement.tagName}`; const window = aFormLike.rootElement.ownerGlobal; // Get password field to get better form data via LoginManagerChild. let passwordField; for (const field of aFormLike.elements) { if ( ChromeUtils.getClassName(field) === "HTMLInputElement" && field.type == "password" ) { passwordField = field; break; } } const loginManagerChild = lazy.LoginManagerChild.forWindow(window); const docState = loginManagerChild.stateForDocument( passwordField.ownerDocument ); const [usernameField] = docState.getUserNameAndPasswordFields( passwordField || aFormLike.elements[0] ); const focusedElement = aFormLike.rootElement.ownerDocument.activeElement; let sendFocusEvent = aFormLike.rootElement === focusedElement; const rootInfo = this._getInfo( aFormLike.rootElement, null, undefined, null ); rootInfo.rootUuid = rootInfo.uuid; rootInfo.children = aFormLike.elements .filter( element => element.type != "hidden" && (!usernameField || element.type != "text" || element == usernameField || (element.getAutocompleteInfo() && element.getAutocompleteInfo().fieldName == "email")) ) .map(element => { sendFocusEvent |= element === focusedElement; return this._getInfo( element, rootInfo.uuid, rootInfo.uuid, usernameField ); }); try { // We don't await here so that we can send a focus event immediately // after this as the app might not know which element is focused. const responsePromise = this.sendQuery("Add", { node: rootInfo, }); if (sendFocusEvent) { // We might have missed sending a focus event for the active element. this.onFocus(aFormLike.ownerDocument.activeElement); } const responses = await responsePromise; // `responses` is an object with global IDs as keys. debug`Performing auto-fill ${Object.keys(responses)}`; const AUTOFILL_STATE = "autofill"; const winUtils = window.windowUtils; for (const uuid in responses) { const entry = this._autofillElements && this._autofillElements.get(uuid); const element = entry && entry.get(); const value = responses[uuid] || ""; if ( window.HTMLInputElement.isInstance(element) && !element.disabled && element.parentElement ) { element.setUserInput(value); if (winUtils && element.value === value) { // Add highlighting for autofilled fields. winUtils.addManuallyManagedState(element, AUTOFILL_STATE); // Remove highlighting when the field is changed. element.addEventListener( "input", _ => winUtils.removeManuallyManagedState(element, AUTOFILL_STATE), { mozSystemGroup: true, once: true } ); } } else if (element) { warn`Don't know how to auto-fill ${element.tagName}`; } } } catch (error) { warn`Cannot perform autofill ${error}`; } } _getInfo(aElement, aParent, aRoot, aUsernameField) { if (!this._autofillInfos) { this._autofillInfos = new WeakMap(); this._autofillElements = new Map(); } let info = this._autofillInfos.get(aElement); if (info) { return info; } const window = aElement.ownerGlobal; const bounds = aElement.getBoundingClientRect(); const isInputElement = window.HTMLInputElement.isInstance(aElement); info = { isInputElement, uuid: Services.uuid.generateUUID().toString().slice(1, -1), // discard the surrounding curly braces parentUuid: aParent, rootUuid: aRoot, tag: aElement.tagName, type: isInputElement ? aElement.type : null, value: isInputElement ? aElement.value : null, editable: isInputElement && [ "color", "date", "datetime-local", "email", "month", "number", "password", "range", "search", "tel", "text", "time", "url", "week", ].includes(aElement.type), disabled: isInputElement ? aElement.disabled : null, attributes: Object.assign( {}, ...Array.from(aElement.attributes) .filter(attr => attr.localName !== "value") .map(attr => ({ [attr.localName]: attr.value })) ), origin: aElement.ownerDocument.location.origin, autofillhint: "", bounds: { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom, }, }; if (aElement === aUsernameField) { info.autofillhint = "username"; // AUTOFILL.HINT.USERNAME } else if (isInputElement) { // Using autocomplete attribute if it is email. const autocompleteInfo = aElement.getAutocompleteInfo(); if (autocompleteInfo) { const autocompleteAttr = autocompleteInfo.fieldName; if (autocompleteAttr == "email") { info.type = "email"; } } } this._autofillInfos.set(aElement, info); this._autofillElements.set(info.uuid, Cu.getWeakReference(aElement)); return info; } _updateInfoValues(aElements) { if (!this._autofillInfos) { return []; } const updated = []; for (const element of aElements) { const info = this._autofillInfos.get(element); if (!info?.isInputElement || info.value === element.value) { continue; } debug`Updating value ${info.value} to ${element.value}`; info.value = element.value; this._autofillInfos.set(element, info); updated.push(info); } return updated; } /** * Called when an auto-fillable field is focused or blurred. * * @param aTarget Focused element, or null if an element has lost focus. */ onFocus(aTarget) { debug`Auto-fill focus on ${aTarget && aTarget.tagName}`; const info = aTarget && this._autofillInfos?.get(aTarget); if (info) { const bounds = aTarget.getBoundingClientRect(); const screenRect = lazy.LayoutUtils.rectToScreenRect( aTarget.ownerGlobal, bounds ); info.screenRect = { left: screenRect.left, top: screenRect.top, right: screenRect.right, bottom: screenRect.bottom, }; } if (!aTarget || info) { this.sendAsyncMessage("Focus", { node: info, }); } } commitAutofill(aFormLike) { if (!aFormLike) { throw new Error("null-form on autofill commit"); } debug`Committing auto-fill for ${aFormLike.rootElement.tagName}`; const updatedNodeInfos = this._updateInfoValues([ aFormLike.rootElement, ...aFormLike.elements, ]); for (const updatedInfo of updatedNodeInfos) { debug`Updating node ${updatedInfo}`; this.sendAsyncMessage("Update", { node: updatedInfo, }); } const info = this._getInfo(aFormLike.rootElement); if (info) { debug`Committing node ${info}`; this.sendAsyncMessage("Commit", { node: info, }); } } /** * Clear all tracked auto-fill forms and notify Java. */ clearElements(browsingContext) { this._autofillInfos = undefined; this._autofillElements = undefined; if (browsingContext === browsingContext.top) { this.sendAsyncMessage("Clear"); } } /** * Scan for auto-fillable forms and add them if necessary. Called when a page * is navigated to through history, in which case we don't get our typical * "input added" notifications. * * @param aDoc Document to scan. */ scanDocument(aDoc) { // Add forms first; only check forms with password inputs. const inputs = aDoc.querySelectorAll("input[type=password]"); let inputAdded = false; for (let i = 0; i < inputs.length; i++) { if (inputs[i].form) { // Let addElement coalesce multiple calls for the same form. this.addElement(lazy.FormLikeFactory.createFromForm(inputs[i].form)); } else if (!inputAdded) { // Treat inputs without forms as one unit, and process them only once. inputAdded = true; this.addElement(lazy.FormLikeFactory.createFromField(inputs[i])); } } } } const { debug, warn } = GeckoViewAutoFillChild.initLogging("GeckoViewAutoFill");