diff options
Diffstat (limited to 'toolkit/components/formautofill/FormAutofillParent.sys.mjs')
-rw-r--r-- | toolkit/components/formautofill/FormAutofillParent.sys.mjs | 607 |
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(); + } + })() + ) + ); + } +} |