summaryrefslogtreecommitdiffstats
path: root/toolkit/components/formautofill/FormAutofillParent.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/formautofill/FormAutofillParent.sys.mjs')
-rw-r--r--toolkit/components/formautofill/FormAutofillParent.sys.mjs607
1 files changed, 607 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/FormAutofillParent.sys.mjs b/toolkit/components/formautofill/FormAutofillParent.sys.mjs
new file mode 100644
index 0000000000..c0ae98b851
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillParent.sys.mjs
@@ -0,0 +1,607 @@
+/* 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/. */
+
+/*
+ * Implements a service used to access storage and communicate with content.
+ *
+ * A "fields" array is used to communicate with FormAutofillContent. Each item
+ * represents a single input field in the content page as well as its
+ * @autocomplete properties. The schema is as below. Please refer to
+ * FormAutofillContent.js for more details.
+ *
+ * [
+ * {
+ * section,
+ * addressType,
+ * contactType,
+ * fieldName,
+ * value,
+ * index
+ * },
+ * {
+ * // ...
+ * }
+ * ]
+ */
+
+// We expose a singleton from this module. Some tests may import the
+// constructor via a backstage pass.
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddressComponent: "resource://gre/modules/shared/AddressComponent.sys.mjs",
+ FormAutofillPreferences:
+ "resource://autofill/FormAutofillPreferences.sys.mjs",
+ FormAutofillPrompter: "resource://autofill/FormAutofillPrompter.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () =>
+ FormAutofill.defineLogGetter(lazy, "FormAutofillParent")
+);
+
+const { ENABLED_AUTOFILL_ADDRESSES_PREF, ENABLED_AUTOFILL_CREDITCARDS_PREF } =
+ FormAutofill;
+
+const { ADDRESSES_COLLECTION_NAME, CREDITCARDS_COLLECTION_NAME } =
+ FormAutofillUtils;
+
+let gMessageObservers = new Set();
+
+export let FormAutofillStatus = {
+ _initialized: false,
+
+ /**
+ * Cache of the Form Autofill status (considering preferences and storage).
+ */
+ _active: null,
+
+ /**
+ * Initializes observers and registers the message handler.
+ */
+ init() {
+ if (this._initialized) {
+ return;
+ }
+ this._initialized = true;
+
+ Services.obs.addObserver(this, "privacy-pane-loaded");
+
+ // Observing the pref and storage changes
+ Services.prefs.addObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this);
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+
+ // Only listen to credit card related preference if it is available
+ if (FormAutofill.isAutofillCreditCardsAvailable) {
+ Services.prefs.addObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this);
+ }
+
+ // We have to use empty window type to get all opened windows here because the
+ // window type parameter may not be available during startup.
+ for (let win of Services.wm.getEnumerator("")) {
+ let { documentElement } = win.document;
+ if (documentElement?.getAttribute("windowtype") == "navigator:browser") {
+ this.injectElements(win.document);
+ } else {
+ // Manually call onOpenWindow for windows that are already opened but not
+ // yet have the window type set. This ensures we inject the elements we need
+ // when its docuemnt is ready.
+ this.onOpenWindow(win);
+ }
+ }
+ Services.wm.addListener(this);
+
+ Services.telemetry.setEventRecordingEnabled("creditcard", true);
+ Services.telemetry.setEventRecordingEnabled("address", true);
+ },
+
+ /**
+ * Uninitializes FormAutofillStatus. This is for testing only.
+ *
+ * @private
+ */
+ uninit() {
+ lazy.gFormAutofillStorage._saveImmediately();
+
+ if (!this._initialized) {
+ return;
+ }
+ this._initialized = false;
+
+ this._active = null;
+
+ Services.obs.removeObserver(this, "privacy-pane-loaded");
+ Services.prefs.removeObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this);
+ Services.wm.removeListener(this);
+
+ if (FormAutofill.isAutofillCreditCardsAvailable) {
+ Services.prefs.removeObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this);
+ }
+ },
+
+ get formAutofillStorage() {
+ return lazy.gFormAutofillStorage;
+ },
+
+ /**
+ * Broadcast the status to frames when the form autofill status changes.
+ */
+ onStatusChanged() {
+ lazy.log.debug("onStatusChanged: Status changed to", this._active);
+ Services.ppmm.sharedData.set("FormAutofill:enabled", this._active);
+ // Sync autofill enabled to make sure the value is up-to-date
+ // no matter when the new content process is initialized.
+ Services.ppmm.sharedData.flush();
+ },
+
+ /**
+ * Query preference and storage status to determine the overall status of the
+ * form autofill feature.
+ *
+ * @returns {boolean} whether form autofill is active (enabled and has data)
+ */
+ computeStatus() {
+ const savedFieldNames = Services.ppmm.sharedData.get(
+ "FormAutofill:savedFieldNames"
+ );
+
+ return (
+ (Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF) ||
+ Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF)) &&
+ savedFieldNames &&
+ savedFieldNames.size > 0
+ );
+ },
+
+ /**
+ * Update the status and trigger onStatusChanged, if necessary.
+ */
+ updateStatus() {
+ lazy.log.debug("updateStatus");
+ let wasActive = this._active;
+ this._active = this.computeStatus();
+ if (this._active !== wasActive) {
+ this.onStatusChanged();
+ }
+ },
+
+ async updateSavedFieldNames() {
+ lazy.log.debug("updateSavedFieldNames");
+
+ let savedFieldNames;
+ const addressNames =
+ await lazy.gFormAutofillStorage.addresses.getSavedFieldNames();
+
+ // Don't access the credit cards store unless it is enabled.
+ if (FormAutofill.isAutofillCreditCardsAvailable) {
+ const creditCardNames =
+ await lazy.gFormAutofillStorage.creditCards.getSavedFieldNames();
+ savedFieldNames = new Set([...addressNames, ...creditCardNames]);
+ } else {
+ savedFieldNames = addressNames;
+ }
+
+ Services.ppmm.sharedData.set(
+ "FormAutofill:savedFieldNames",
+ savedFieldNames
+ );
+ Services.ppmm.sharedData.flush();
+
+ this.updateStatus();
+ },
+
+ injectElements(doc) {
+ Services.scriptloader.loadSubScript(
+ "chrome://formautofill/content/customElements.js",
+ doc.ownerGlobal
+ );
+ },
+
+ onOpenWindow(xulWindow) {
+ const win = xulWindow.docShell.domWindow;
+ win.addEventListener(
+ "load",
+ () => {
+ if (
+ win.document.documentElement.getAttribute("windowtype") ==
+ "navigator:browser"
+ ) {
+ this.injectElements(win.document);
+ }
+ },
+ { once: true }
+ );
+ },
+
+ onCloseWindow() {},
+
+ async observe(subject, topic, data) {
+ lazy.log.debug("observe:", topic, "with data:", data);
+ switch (topic) {
+ case "privacy-pane-loaded": {
+ let formAutofillPreferences = new lazy.FormAutofillPreferences();
+ let document = subject.document;
+ let prefFragment = formAutofillPreferences.init(document);
+ let formAutofillGroupBox = document.getElementById(
+ "formAutofillGroupBox"
+ );
+ formAutofillGroupBox.appendChild(prefFragment);
+ break;
+ }
+
+ case "nsPref:changed": {
+ // Observe pref changes and update _active cache if status is changed.
+ this.updateStatus();
+ break;
+ }
+
+ case "formautofill-storage-changed": {
+ // Early exit if only metadata is changed
+ if (data == "notifyUsed") {
+ break;
+ }
+
+ await this.updateSavedFieldNames();
+ break;
+ }
+
+ default: {
+ throw new Error(
+ `FormAutofillStatus: Unexpected topic observed: ${topic}`
+ );
+ }
+ }
+ },
+};
+
+// Lazily load the storage JSM to avoid disk I/O until absolutely needed.
+// Once storage is loaded we need to update saved field names and inform content processes.
+XPCOMUtils.defineLazyGetter(lazy, "gFormAutofillStorage", () => {
+ let { formAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ );
+ lazy.log.debug("Loading formAutofillStorage");
+
+ formAutofillStorage.initialize().then(() => {
+ // Update the saved field names to compute the status and update child processes.
+ FormAutofillStatus.updateSavedFieldNames();
+ });
+
+ return formAutofillStorage;
+});
+
+export class FormAutofillParent extends JSWindowActorParent {
+ constructor() {
+ super();
+ FormAutofillStatus.init();
+ }
+
+ static addMessageObserver(observer) {
+ gMessageObservers.add(observer);
+ }
+
+ static removeMessageObserver(observer) {
+ gMessageObservers.delete(observer);
+ }
+
+ /**
+ * Handles the message coming from FormAutofillContent.
+ *
+ * @param {object} message
+ * @param {string} message.name The name of the message.
+ * @param {object} message.data The data of the message.
+ */
+ async receiveMessage({ name, data }) {
+ switch (name) {
+ case "FormAutofill:InitStorage": {
+ await lazy.gFormAutofillStorage.initialize();
+ await FormAutofillStatus.updateSavedFieldNames();
+ break;
+ }
+ case "FormAutofill:GetRecords": {
+ return FormAutofillParent._getRecords(data);
+ }
+ case "FormAutofill:OnFormSubmit": {
+ this.notifyMessageObservers("onFormSubmitted", data);
+ await this._onFormSubmit(data);
+ break;
+ }
+ case "FormAutofill:OpenPreferences": {
+ const win = lazy.BrowserWindowTracker.getTopWindow();
+ win.openPreferences("privacy-form-autofill");
+ break;
+ }
+ case "FormAutofill:GetDecryptedString": {
+ let { cipherText, reauth } = data;
+ if (!FormAutofillUtils._reauthEnabledByUser) {
+ lazy.log.debug("Reauth is disabled");
+ reauth = false;
+ }
+ let string;
+ try {
+ string = await lazy.OSKeyStore.decrypt(cipherText, reauth);
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_ABORT) {
+ throw e;
+ }
+ lazy.log.warn("User canceled encryption login");
+ }
+ return string;
+ }
+ case "FormAutofill:UpdateWarningMessage":
+ this.notifyMessageObservers("updateWarningNote", data);
+ break;
+
+ case "FormAutofill:FieldsIdentified":
+ this.notifyMessageObservers("fieldsIdentified", data);
+ break;
+
+ // The remaining Save and Remove messages are invoked only by tests.
+ case "FormAutofill:SaveAddress": {
+ if (data.guid) {
+ await lazy.gFormAutofillStorage.addresses.update(
+ data.guid,
+ data.address
+ );
+ } else {
+ await lazy.gFormAutofillStorage.addresses.add(data.address);
+ }
+ break;
+ }
+ case "FormAutofill:SaveCreditCard": {
+ if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) {
+ lazy.log.warn("User canceled encryption login");
+ return undefined;
+ }
+ await lazy.gFormAutofillStorage.creditCards.add(data.creditcard);
+ break;
+ }
+ case "FormAutofill:RemoveAddresses": {
+ data.guids.forEach(guid =>
+ lazy.gFormAutofillStorage.addresses.remove(guid)
+ );
+ break;
+ }
+ case "FormAutofill:RemoveCreditCards": {
+ data.guids.forEach(guid =>
+ lazy.gFormAutofillStorage.creditCards.remove(guid)
+ );
+ break;
+ }
+ }
+
+ return undefined;
+ }
+
+ notifyMessageObservers(callbackName, data) {
+ for (let observer of gMessageObservers) {
+ try {
+ if (callbackName in observer) {
+ observer[callbackName](
+ data,
+ this.manager.browsingContext.topChromeWindow
+ );
+ }
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ /**
+ * Get the records from profile store and return results back to content
+ * process. It will decrypt the credit card number and append
+ * "cc-number-decrypted" to each record if OSKeyStore isn't set.
+ *
+ * This is static as a unit test calls this.
+ *
+ * @private
+ * @param {object} data
+ * @param {string} data.collectionName
+ * The name used to specify which collection to retrieve records.
+ * @param {string} data.searchString
+ * The typed string for filtering out the matched records.
+ * @param {string} data.info
+ * The input autocomplete property's information.
+ */
+ static async _getRecords({ collectionName, searchString, info }) {
+ let collection = lazy.gFormAutofillStorage[collectionName];
+ if (!collection) {
+ return [];
+ }
+
+ let recordsInCollection = await collection.getAll();
+ if (!info || !info.fieldName || !recordsInCollection.length) {
+ return recordsInCollection;
+ }
+
+ let isCC = collectionName == CREDITCARDS_COLLECTION_NAME;
+ // We don't filter "cc-number"
+ if (isCC && info.fieldName == "cc-number") {
+ recordsInCollection = recordsInCollection.filter(
+ record => !!record["cc-number"]
+ );
+ return recordsInCollection;
+ }
+
+ let records = [];
+ let lcSearchString = searchString.toLowerCase();
+
+ for (let record of recordsInCollection) {
+ let fieldValue = record[info.fieldName];
+ if (!fieldValue) {
+ continue;
+ }
+
+ if (
+ collectionName == ADDRESSES_COLLECTION_NAME &&
+ record.country &&
+ !FormAutofill.isAutofillAddressesAvailableInCountry(record.country)
+ ) {
+ // Address autofill isn't supported for the record's country so we don't
+ // want to attempt to potentially incorrectly fill the address fields.
+ continue;
+ }
+
+ if (
+ lcSearchString &&
+ !String(fieldValue).toLowerCase().startsWith(lcSearchString)
+ ) {
+ continue;
+ }
+ records.push(record);
+ }
+
+ return records;
+ }
+
+ async _onAddressSubmit(address, browser) {
+ const storage = lazy.gFormAutofillStorage.addresses;
+
+ // Make sure record is normalized before comparing with records in the storage
+ storage._normalizeRecord(address.record);
+
+ const newAddress = new lazy.AddressComponent(
+ address.record,
+ // Invalid address fields in the address form will not be captured.
+ { ignoreInvalid: true }
+ );
+
+ let mergeableRecord = null;
+ let mergeableFields = [];
+
+ // Exams all stored record to determine whether to show the prompt or not.
+ for (const record of await storage.getAll()) {
+ const savedAddress = new lazy.AddressComponent(record);
+ // filter invalid field
+ const result = newAddress.compare(savedAddress);
+
+ // If any of the fields in the new address are different from the corresponding fields
+ // in the saved address, the two addresses are considered different. For example, if
+ // the name, email, country are the same but the street address is different, the two
+ // addresses are not considered the same.
+ if (Object.values(result).includes("different")) {
+ continue;
+ // If every field of the new address is either the same or is subset of the corresponding
+ // field in the saved address, the new address is duplicated. We don't need capture
+ // the new address.
+ } else if (
+ Object.values(result).every(r => ["same", "subset"].includes(r))
+ ) {
+ lazy.log.debug(
+ "A duplicated address record is found, do not show the prompt"
+ );
+ storage.notifyUsed(record.guid);
+ return false;
+ // If the new address is neither a duplicate of the saved address nor a different address.
+ // There must be at least one field we can merge, show the update doorhanger
+ } else {
+ lazy.log.debug(
+ "A mergeable address record is found, show the update prompt"
+ );
+ // If we find multiple mergeable records, choose the record with fewest mergeable fields.
+ // TODO: Bug 1830841. Add a testcase
+ let fields = Object.entries(result)
+ .filter(v => ["superset", "similar"].includes(v[1]))
+ .map(v => v[0]);
+ if (!mergeableFields.length || mergeableFields.length > fields.length) {
+ mergeableRecord = record;
+ mergeableFields = fields;
+ }
+ }
+ }
+
+ if (
+ !FormAutofill.isAutofillAddressesCaptureEnabled &&
+ !FormAutofill.isAutofillAddressesCaptureV2Enabled
+ ) {
+ return false;
+ }
+
+ return async () => {
+ await lazy.FormAutofillPrompter.promptToSaveAddress(
+ browser,
+ storage,
+ address.record,
+ address.flowId,
+ { mergeableRecord, mergeableFields }
+ );
+ };
+ }
+
+ async _onCreditCardSubmit(creditCard, browser) {
+ // Let's reset the credit card to empty, and then network auto-detect will
+ // pick it up.
+ delete creditCard.record["cc-type"];
+
+ const storage = lazy.gFormAutofillStorage.creditCards;
+ // Make sure record is normalized before comparing with records in the storage
+ storage._normalizeRecord(creditCard.record);
+
+ // If the record alreay exists in the storage, don't bother showing the prompt
+ const matchRecord = (
+ await storage.getMatchRecords(creditCard.record).next()
+ ).value;
+ if (matchRecord) {
+ storage.notifyUsed(matchRecord.guid);
+ return false;
+ }
+
+ // Suppress the pending doorhanger from showing up if user disabled credit card in previous doorhanger.
+ if (!FormAutofill.isAutofillCreditCardsEnabled) {
+ return false;
+ }
+
+ return async () => {
+ await lazy.FormAutofillPrompter.promptToSaveCreditCard(
+ browser,
+ storage,
+ creditCard.record,
+ creditCard.flowId
+ );
+ };
+ }
+
+ async _onFormSubmit(data) {
+ let { address, creditCard } = data;
+
+ let browser = this.manager.browsingContext.top.embedderElement;
+
+ // Transmit the telemetry immediately in the meantime form submitted, and handle these pending
+ // doorhangers at a later.
+ await Promise.all(
+ [
+ await Promise.all(
+ address.map(addrRecord => this._onAddressSubmit(addrRecord, browser))
+ ),
+ await Promise.all(
+ creditCard.map(ccRecord =>
+ this._onCreditCardSubmit(ccRecord, browser)
+ )
+ ),
+ ]
+ .map(pendingDoorhangers => {
+ return pendingDoorhangers.filter(
+ pendingDoorhanger =>
+ !!pendingDoorhanger && typeof pendingDoorhanger == "function"
+ );
+ })
+ .map(pendingDoorhangers =>
+ (async () => {
+ for (const showDoorhanger of pendingDoorhangers) {
+ await showDoorhanger();
+ }
+ })()
+ )
+ );
+ }
+}