tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

GeckoViewAutoFillChild.sys.mjs (11813B)


      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/. */
      4 
      5 import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs";
      6 import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs";
      7 
      8 const lazy = {};
      9 
     10 ChromeUtils.defineESModuleGetters(lazy, {
     11  FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
     12  LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs",
     13  LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs",
     14 });
     15 
     16 export class GeckoViewAutoFillChild extends GeckoViewActorChild {
     17  constructor() {
     18    super();
     19 
     20    this._autofillElements = undefined;
     21    this._autofillInfos = undefined;
     22  }
     23 
     24  // eslint-disable-next-line complexity
     25  handleEvent(aEvent) {
     26    debug`handleEvent: ${aEvent.type}`;
     27    switch (aEvent.type) {
     28      case "DOMFormHasPassword": {
     29        this.addElement(
     30          lazy.FormLikeFactory.createFromForm(aEvent.composedTarget)
     31        );
     32        break;
     33      }
     34      case "DOMInputPasswordAdded": {
     35        const input = aEvent.composedTarget;
     36        if (!input.form) {
     37          this.addElement(lazy.FormLikeFactory.createFromField(input));
     38        }
     39        break;
     40      }
     41      case "focusin": {
     42        const element = aEvent.composedTarget;
     43        if (!this.contentWindow.HTMLInputElement.isInstance(element)) {
     44          break;
     45        }
     46        GeckoViewUtils.waitForPanZoomState(this.contentWindow).finally(() => {
     47          if (Cu.isDeadWrapper(element)) {
     48            // Focus element is removed or document is navigated to new page.
     49            return;
     50          }
     51          const focusedElement =
     52            Services.focus.focusedElement ||
     53            element.ownerDocument?.activeElement;
     54          if (element == focusedElement) {
     55            this.onFocus(focusedElement);
     56          }
     57        });
     58        break;
     59      }
     60      case "focusout": {
     61        if (
     62          this.contentWindow.HTMLInputElement.isInstance(aEvent.composedTarget)
     63        ) {
     64          this.onFocus(null);
     65        }
     66        break;
     67      }
     68      case "pagehide": {
     69        if (aEvent.target === this.document) {
     70          this.clearElements(this.browsingContext);
     71        }
     72        break;
     73      }
     74      case "pageshow": {
     75        if (aEvent.target === this.document) {
     76          this.scanDocument(this.document);
     77        }
     78        break;
     79      }
     80      case "PasswordManager:ShowDoorhanger": {
     81        const { form: formLike } = aEvent.detail;
     82        this.commitAutofill(formLike);
     83        break;
     84      }
     85    }
     86  }
     87 
     88  /**
     89   * Process an auto-fillable form and send the relevant details of the form
     90   * to Java. Multiple calls within a short time period for the same form are
     91   * coalesced, so that, e.g., if multiple inputs are added to a form in
     92   * succession, we will only perform one processing pass. Note that for inputs
     93   * without forms, FormLikeFactory treats the document as the "form", but
     94   * there is no difference in how we process them.
     95   *
     96   * @param aFormLike A FormLike object produced by FormLikeFactory.
     97   */
     98  async addElement(aFormLike) {
     99    debug`Adding auto-fill ${aFormLike.rootElement.tagName}`;
    100 
    101    const window = aFormLike.rootElement.ownerGlobal;
    102    // Get password field to get better form data via LoginManagerChild.
    103    let passwordField;
    104    for (const field of aFormLike.elements) {
    105      if (
    106        ChromeUtils.getClassName(field) === "HTMLInputElement" &&
    107        field.type == "password"
    108      ) {
    109        passwordField = field;
    110        break;
    111      }
    112    }
    113 
    114    const loginManagerChild = lazy.LoginManagerChild.forWindow(window);
    115    const docState = loginManagerChild.stateForDocument(
    116      passwordField.ownerDocument
    117    );
    118    const [usernameField] = docState.getUserNameAndPasswordFields(
    119      passwordField || aFormLike.elements[0]
    120    );
    121 
    122    const focusedElement = aFormLike.rootElement.ownerDocument.activeElement;
    123    let sendFocusEvent = aFormLike.rootElement === focusedElement;
    124 
    125    const rootInfo = this._getInfo(
    126      aFormLike.rootElement,
    127      null,
    128      undefined,
    129      null
    130    );
    131 
    132    rootInfo.rootUuid = rootInfo.uuid;
    133    rootInfo.children = aFormLike.elements
    134      .filter(
    135        element =>
    136          element.type != "hidden" &&
    137          (!usernameField ||
    138            element.type != "text" ||
    139            element == usernameField ||
    140            (element.getAutocompleteInfo() &&
    141              element.getAutocompleteInfo().fieldName == "email"))
    142      )
    143      .map(element => {
    144        sendFocusEvent |= element === focusedElement;
    145        return this._getInfo(
    146          element,
    147          rootInfo.uuid,
    148          rootInfo.uuid,
    149          usernameField
    150        );
    151      });
    152 
    153    try {
    154      // We don't await here so that we can send a focus event immediately
    155      // after this as the app might not know which element is focused.
    156      const responsePromise = this.sendQuery("Add", {
    157        node: rootInfo,
    158      });
    159 
    160      if (sendFocusEvent) {
    161        // We might have missed sending a focus event for the active element.
    162        this.onFocus(aFormLike.ownerDocument.activeElement);
    163      }
    164 
    165      const responses = await responsePromise;
    166      // `responses` is an object with global IDs as keys.
    167      debug`Performing auto-fill ${Object.keys(responses)}`;
    168 
    169      const AUTOFILL_STATE = "autofill";
    170 
    171      for (const uuid in responses) {
    172        const entry =
    173          this._autofillElements && this._autofillElements.get(uuid);
    174        const element = entry && entry.get();
    175        const value = responses[uuid] || "";
    176 
    177        if (
    178          window.HTMLInputElement.isInstance(element) &&
    179          !element.disabled &&
    180          element.parentElement
    181        ) {
    182          element.setUserInput(value);
    183          if (element.value === value) {
    184            // Add highlighting for autofilled fields.
    185            element.autofillState = AUTOFILL_STATE;
    186 
    187            // Remove highlighting when the field is changed.
    188            element.addEventListener(
    189              "input",
    190              _ => (element.autofillState = ""),
    191              { mozSystemGroup: true, once: true }
    192            );
    193          }
    194        } else if (element) {
    195          warn`Don't know how to auto-fill ${element.tagName}`;
    196        }
    197      }
    198    } catch (error) {
    199      warn`Cannot perform autofill ${error}`;
    200    }
    201  }
    202 
    203  _getInfo(aElement, aParent, aRoot, aUsernameField) {
    204    if (!this._autofillInfos) {
    205      this._autofillInfos = new WeakMap();
    206      this._autofillElements = new Map();
    207    }
    208 
    209    let info = this._autofillInfos.get(aElement);
    210    if (info) {
    211      return info;
    212    }
    213 
    214    const window = aElement.ownerGlobal;
    215    const bounds = aElement.getBoundingClientRect();
    216    const isInputElement = window.HTMLInputElement.isInstance(aElement);
    217 
    218    info = {
    219      isInputElement,
    220      uuid: Services.uuid.generateUUID().toString().slice(1, -1), // discard the surrounding curly braces
    221      parentUuid: aParent,
    222      rootUuid: aRoot,
    223      tag: aElement.tagName,
    224      type: isInputElement ? aElement.type : null,
    225      value: isInputElement ? aElement.value : null,
    226      editable:
    227        isInputElement &&
    228        [
    229          "color",
    230          "date",
    231          "datetime-local",
    232          "email",
    233          "month",
    234          "number",
    235          "password",
    236          "range",
    237          "search",
    238          "tel",
    239          "text",
    240          "time",
    241          "url",
    242          "week",
    243        ].includes(aElement.type),
    244      disabled: isInputElement ? aElement.disabled : null,
    245      attributes: Object.assign(
    246        {},
    247        ...Array.from(aElement.attributes)
    248          .filter(attr => attr.localName !== "value")
    249          .map(attr => ({ [attr.localName]: attr.value }))
    250      ),
    251      origin: aElement.ownerDocument.location.origin,
    252      autofillhint: "",
    253      bounds: {
    254        left: bounds.left,
    255        top: bounds.top,
    256        right: bounds.right,
    257        bottom: bounds.bottom,
    258      },
    259    };
    260 
    261    if (aElement === aUsernameField) {
    262      info.autofillhint = "username"; // AUTOFILL.HINT.USERNAME
    263    } else if (isInputElement) {
    264      // Using autocomplete attribute if it is email.
    265      const autocompleteInfo = aElement.getAutocompleteInfo();
    266      if (autocompleteInfo) {
    267        const autocompleteAttr = autocompleteInfo.fieldName;
    268        if (autocompleteAttr == "email") {
    269          info.type = "email";
    270        }
    271      }
    272    }
    273 
    274    this._autofillInfos.set(aElement, info);
    275    this._autofillElements.set(info.uuid, Cu.getWeakReference(aElement));
    276    return info;
    277  }
    278 
    279  _updateInfoValues(aElements) {
    280    if (!this._autofillInfos) {
    281      return [];
    282    }
    283 
    284    const updated = [];
    285    for (const element of aElements) {
    286      const info = this._autofillInfos.get(element);
    287 
    288      if (!info?.isInputElement || info.value === element.value) {
    289        continue;
    290      }
    291      debug`Updating value ${info.value} to ${element.value}`;
    292 
    293      info.value = element.value;
    294      this._autofillInfos.set(element, info);
    295      updated.push(info);
    296    }
    297    return updated;
    298  }
    299 
    300  /**
    301   * Called when an auto-fillable field is focused or blurred.
    302   *
    303   * @param aTarget Focused element, or null if an element has lost focus.
    304   */
    305  onFocus(aTarget) {
    306    debug`Auto-fill focus on ${aTarget && aTarget.tagName}`;
    307 
    308    const info = aTarget && this._autofillInfos?.get(aTarget);
    309    if (info) {
    310      const bounds = aTarget.getBoundingClientRect();
    311      const screenRect = lazy.LayoutUtils.rectToScreenRect(
    312        aTarget.ownerGlobal,
    313        bounds
    314      );
    315      info.screenRect = {
    316        left: screenRect.left,
    317        top: screenRect.top,
    318        right: screenRect.right,
    319        bottom: screenRect.bottom,
    320      };
    321    }
    322 
    323    if (!aTarget || info) {
    324      this.sendAsyncMessage("Focus", {
    325        node: info,
    326      });
    327    }
    328  }
    329 
    330  commitAutofill(aFormLike) {
    331    if (!aFormLike) {
    332      throw new Error("null-form on autofill commit");
    333    }
    334 
    335    debug`Committing auto-fill for ${aFormLike.rootElement.tagName}`;
    336 
    337    const updatedNodeInfos = this._updateInfoValues([
    338      aFormLike.rootElement,
    339      ...aFormLike.elements,
    340    ]);
    341 
    342    for (const updatedInfo of updatedNodeInfos) {
    343      debug`Updating node ${updatedInfo}`;
    344      this.sendAsyncMessage("Update", {
    345        node: updatedInfo,
    346      });
    347    }
    348 
    349    const info = this._getInfo(aFormLike.rootElement);
    350    if (info) {
    351      debug`Committing node ${info}`;
    352      this.sendAsyncMessage("Commit", {
    353        node: info,
    354      });
    355    }
    356  }
    357 
    358  /**
    359   * Clear all tracked auto-fill forms and notify Java.
    360   */
    361  clearElements(browsingContext) {
    362    this._autofillInfos = undefined;
    363    this._autofillElements = undefined;
    364 
    365    if (browsingContext === browsingContext.top) {
    366      this.sendAsyncMessage("Clear");
    367    }
    368  }
    369 
    370  /**
    371   * Scan for auto-fillable forms and add them if necessary. Called when a page
    372   * is navigated to through history, in which case we don't get our typical
    373   * "input added" notifications.
    374   *
    375   * @param aDoc Document to scan.
    376   */
    377  scanDocument(aDoc) {
    378    // Add forms first; only check forms with password inputs.
    379    const inputs = aDoc.querySelectorAll("input[type=password]");
    380    let inputAdded = false;
    381    for (let i = 0; i < inputs.length; i++) {
    382      if (inputs[i].form) {
    383        // Let addElement coalesce multiple calls for the same form.
    384        this.addElement(lazy.FormLikeFactory.createFromForm(inputs[i].form));
    385      } else if (!inputAdded) {
    386        // Treat inputs without forms as one unit, and process them only once.
    387        inputAdded = true;
    388        this.addElement(lazy.FormLikeFactory.createFromField(inputs[i]));
    389      }
    390    }
    391  }
    392 }
    393 
    394 const { debug, warn } = GeckoViewAutoFillChild.initLogging("GeckoViewAutoFill");