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/. */
6 * Implements a service used to access storage and communicate with content.
8 * A "fields" array is used to communicate with FormAutofillChild. Each item
9 * represents a single input field in the content page as well as its
10 * @autocomplete properties. The schema is as below. Please refer to
11 * FormAutofillChild.js for more details.
28 // We expose a singleton from this module. Some tests may import the
29 // constructor via a backstage pass.
30 import { FirefoxRelayTelemetry } from "resource://gre/modules/FirefoxRelayTelemetry.mjs";
31 import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
32 import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
36 ChromeUtils.defineESModuleGetters(lazy, {
37 AddressComponent: "resource://gre/modules/shared/AddressComponent.sys.mjs",
38 BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
39 FormAutofillPreferences:
40 "resource://autofill/FormAutofillPreferences.sys.mjs",
41 FormAutofillPrompter: "resource://autofill/FormAutofillPrompter.sys.mjs",
42 FirefoxRelay: "resource://gre/modules/FirefoxRelay.sys.mjs",
43 LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
44 OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
47 ChromeUtils.defineLazyGetter(lazy, "log", () =>
48 FormAutofill.defineLogGetter(lazy, "FormAutofillParent")
51 const { ENABLED_AUTOFILL_ADDRESSES_PREF, ENABLED_AUTOFILL_CREDITCARDS_PREF } =
54 const { ADDRESSES_COLLECTION_NAME, CREDITCARDS_COLLECTION_NAME } =
57 let gMessageObservers = new Set();
59 export let FormAutofillStatus = {
63 * Cache of the Form Autofill status (considering preferences and storage).
68 * Initializes observers and registers the message handler.
71 if (this._initialized) {
74 this._initialized = true;
76 Services.obs.addObserver(this, "privacy-pane-loaded");
78 // Observing the pref and storage changes
79 Services.prefs.addObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this);
80 Services.obs.addObserver(this, "formautofill-storage-changed");
82 // Only listen to credit card related preference if it is available
83 if (FormAutofill.isAutofillCreditCardsAvailable) {
84 Services.prefs.addObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this);
87 Services.telemetry.setEventRecordingEnabled("creditcard", true);
88 Services.telemetry.setEventRecordingEnabled("address", true);
92 * Uninitializes FormAutofillStatus. This is for testing only.
97 lazy.gFormAutofillStorage._saveImmediately();
99 if (!this._initialized) {
102 this._initialized = false;
106 Services.obs.removeObserver(this, "privacy-pane-loaded");
107 Services.prefs.removeObserver(ENABLED_AUTOFILL_ADDRESSES_PREF, this);
108 Services.wm.removeListener(this);
110 if (FormAutofill.isAutofillCreditCardsAvailable) {
111 Services.prefs.removeObserver(ENABLED_AUTOFILL_CREDITCARDS_PREF, this);
115 get formAutofillStorage() {
116 return lazy.gFormAutofillStorage;
120 * Broadcast the status to frames when the form autofill status changes.
123 lazy.log.debug("onStatusChanged: Status changed to", this._active);
124 Services.ppmm.sharedData.set("FormAutofill:enabled", this._active);
125 // Sync autofill enabled to make sure the value is up-to-date
126 // no matter when the new content process is initialized.
127 Services.ppmm.sharedData.flush();
131 * Query preference and storage status to determine the overall status of the
132 * form autofill feature.
134 * @returns {boolean} whether form autofill is active (enabled and has data)
137 const savedFieldNames = Services.ppmm.sharedData.get(
138 "FormAutofill:savedFieldNames"
142 (Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF) ||
143 Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF)) &&
145 savedFieldNames.size > 0
150 * Update the status and trigger onStatusChanged, if necessary.
153 lazy.log.debug("updateStatus");
154 let wasActive = this._active;
155 this._active = this.computeStatus();
156 if (this._active !== wasActive) {
157 this.onStatusChanged();
161 async updateSavedFieldNames() {
162 lazy.log.debug("updateSavedFieldNames");
166 await lazy.gFormAutofillStorage.addresses.getSavedFieldNames();
168 // Don't access the credit cards store unless it is enabled.
169 if (FormAutofill.isAutofillCreditCardsAvailable) {
170 const creditCardNames =
171 await lazy.gFormAutofillStorage.creditCards.getSavedFieldNames();
172 savedFieldNames = new Set([...addressNames, ...creditCardNames]);
174 savedFieldNames = addressNames;
177 Services.ppmm.sharedData.set(
178 "FormAutofill:savedFieldNames",
181 Services.ppmm.sharedData.flush();
186 async observe(subject, topic, data) {
187 lazy.log.debug("observe:", topic, "with data:", data);
189 case "privacy-pane-loaded": {
190 let formAutofillPreferences = new lazy.FormAutofillPreferences();
191 let document = subject.document;
192 let prefFragment = formAutofillPreferences.init(document);
193 let formAutofillGroupBox = document.getElementById(
194 "formAutofillGroupBox"
196 formAutofillGroupBox.appendChild(prefFragment);
200 case "nsPref:changed": {
201 // Observe pref changes and update _active cache if status is changed.
206 case "formautofill-storage-changed": {
207 // Early exit if only metadata is changed
208 if (data == "notifyUsed") {
212 await this.updateSavedFieldNames();
218 `FormAutofillStatus: Unexpected topic observed: ${topic}`
225 // Lazily load the storage JSM to avoid disk I/O until absolutely needed.
226 // Once storage is loaded we need to update saved field names and inform content processes.
227 ChromeUtils.defineLazyGetter(lazy, "gFormAutofillStorage", () => {
228 let { formAutofillStorage } = ChromeUtils.importESModule(
229 "resource://autofill/FormAutofillStorage.sys.mjs"
231 lazy.log.debug("Loading formAutofillStorage");
233 formAutofillStorage.initialize().then(() => {
234 // Update the saved field names to compute the status and update child processes.
235 FormAutofillStatus.updateSavedFieldNames();
238 return formAutofillStorage;
241 export class FormAutofillParent extends JSWindowActorParent {
244 FormAutofillStatus.init();
247 static addMessageObserver(observer) {
248 gMessageObservers.add(observer);
251 static removeMessageObserver(observer) {
252 gMessageObservers.delete(observer);
256 * Handles the message coming from FormAutofillChild.
258 * @param {object} message
259 * @param {string} message.name The name of the message.
260 * @param {object} message.data The data of the message.
262 async receiveMessage({ name, data }) {
264 case "FormAutofill:InitStorage": {
265 await lazy.gFormAutofillStorage.initialize();
266 await FormAutofillStatus.updateSavedFieldNames();
269 case "FormAutofill:GetRecords": {
270 const records = await FormAutofillParent.getRecords(data);
273 case "FormAutofill:OnFormSubmit": {
274 this.notifyMessageObservers("onFormSubmitted", data);
275 await this._onFormSubmit(data);
278 case "FormAutofill:OpenPreferences": {
279 const win = lazy.BrowserWindowTracker.getTopWindow();
280 win.openPreferences("privacy-form-autofill");
283 case "FormAutofill:GetDecryptedString": {
284 let { cipherText, reauth } = data;
285 if (!FormAutofillUtils._reauthEnabledByUser) {
286 lazy.log.debug("Reauth is disabled");
291 string = await lazy.OSKeyStore.decrypt(cipherText, reauth);
293 if (e.result != Cr.NS_ERROR_ABORT) {
296 lazy.log.warn("User canceled encryption login");
300 case "FormAutofill:UpdateWarningMessage":
301 this.notifyMessageObservers("updateWarningNote", data);
304 case "FormAutofill:FieldsIdentified":
305 this.notifyMessageObservers("fieldsIdentified", data);
308 // The remaining Save and Remove messages are invoked only by tests.
309 case "FormAutofill:SaveAddress": {
311 await lazy.gFormAutofillStorage.addresses.update(
316 await lazy.gFormAutofillStorage.addresses.add(data.address);
320 case "FormAutofill:SaveCreditCard": {
321 if (!(await FormAutofillUtils.ensureLoggedIn()).authenticated) {
322 lazy.log.warn("User canceled encryption login");
325 await lazy.gFormAutofillStorage.creditCards.add(data.creditcard);
328 case "FormAutofill:RemoveAddresses": {
329 data.guids.forEach(guid =>
330 lazy.gFormAutofillStorage.addresses.remove(guid)
334 case "FormAutofill:RemoveCreditCards": {
335 data.guids.forEach(guid =>
336 lazy.gFormAutofillStorage.creditCards.remove(guid)
340 case "PasswordManager:offerRelayIntegration": {
341 FirefoxRelayTelemetry.recordRelayOfferedEvent(
343 data.telemetry.flowId,
344 data.telemetry.scenarioName
346 return this.#offerRelayIntegration();
348 case "PasswordManager:generateRelayUsername": {
349 FirefoxRelayTelemetry.recordRelayUsernameFilledEvent(
351 data.telemetry.flowId
353 return this.#generateRelayUsername();
361 return lazy.LoginHelper.getLoginOrigin(
362 this.manager.documentPrincipal?.originNoSuffix
367 return this.browsingContext.topFrameElement;
370 async #offerRelayIntegration() {
371 const browser = this.getRootBrowser();
372 return lazy.FirefoxRelay.offerRelayIntegration(browser, this.formOrigin);
375 async #generateRelayUsername() {
376 const browser = this.getRootBrowser();
377 return lazy.FirefoxRelay.generateUsername(browser, this.formOrigin);
380 notifyMessageObservers(callbackName, data) {
381 for (let observer of gMessageObservers) {
383 if (callbackName in observer) {
384 observer[callbackName](
386 this.manager.browsingContext.topChromeWindow
396 * Retrieves autocomplete entries for a given search string and data context.
398 * @param {string} searchString
399 * The search string used to filter autocomplete entries.
400 * @param {object} options
401 * @param {string} options.fieldName
402 * The name of the field for which autocomplete entries are being fetched.
403 * @param {string} options.scenarioName
404 * The scenario name used in the autocomplete operation to fetch external entries.
405 * @returns {Promise<object>} A promise that resolves to an object containing two properties: `records` and `externalEntries`.
406 * `records` is an array of autofill records from the form's internal data, sorted by `timeLastUsed`.
407 * `externalEntries` is an array of external autocomplete items fetched based on the scenario.
409 async searchAutoCompleteEntries(searchString, options) {
410 const { fieldName, scenarioName } = options;
411 const relayPromise = lazy.FirefoxRelay.autocompleteItemsAsync({
412 formOrigin: this.formOrigin,
414 hasInput: !!searchString?.length,
417 const recordsPromise = FormAutofillParent.getRecords({
421 const [records, externalEntries] = await Promise.all([
426 // Sort addresses by timeLastUsed for showing the lastest used address at top.
427 records.sort((a, b) => b.timeLastUsed - a.timeLastUsed);
429 return { records, externalEntries };
433 * Get the records from profile store and return results back to content
434 * process. It will decrypt the credit card number and append
435 * "cc-number-decrypted" to each record if OSKeyStore isn't set.
437 * This is static as a unit test calls this.
439 * @param {object} data
440 * @param {string} data.searchString
441 * The typed string for filtering out the matched records.
442 * @param {string} data.collectionName
443 * The name used to specify which collection to retrieve records.
444 * @param {string} data.fieldName
445 * The field name to search. If not specified, return all records in
448 static async getRecords({ searchString, collectionName, fieldName }) {
449 // Derive the collection name from field name if it doesn't exist
451 FormAutofillUtils.getCollectionNameFromFieldName(fieldName);
453 const collection = lazy.gFormAutofillStorage[collectionName];
458 const records = await collection.getAll();
459 if (!fieldName || !records.length) {
463 // We don't filter "cc-number"
464 if (collectionName == CREDITCARDS_COLLECTION_NAME) {
465 if (fieldName == "cc-number") {
466 return records.filter(record => !!record["cc-number"]);
470 const lcSearchString = searchString.toLowerCase();
471 return records.filter(record => {
472 const fieldValue = record[fieldName];
478 collectionName == ADDRESSES_COLLECTION_NAME &&
480 !FormAutofill.isAutofillAddressesAvailableInCountry(record.country)
482 // Address autofill isn't supported for the record's country so we don't
483 // want to attempt to potentially incorrectly fill the address fields.
489 String(fieldValue).toLowerCase().startsWith(lcSearchString)
494 async _onAddressSubmit(address, browser) {
495 const storage = lazy.gFormAutofillStorage.addresses;
497 // Make sure record is normalized before comparing with records in the storage
499 storage._normalizeRecord(address.record);
504 const newAddress = new lazy.AddressComponent(
506 // Invalid address fields in the address form will not be captured.
507 { ignoreInvalid: true }
510 // Exams all stored record to determine whether to show the prompt or not.
511 let mergeableFields = [];
512 let preserveFields = [];
515 for (const record of await storage.getAll()) {
516 const savedAddress = new lazy.AddressComponent(record);
517 // filter invalid field
518 const result = newAddress.compare(savedAddress);
520 // If any of the fields in the new address are different from the corresponding fields
521 // in the saved address, the two addresses are considered different. For example, if
522 // the name, email, country are the same but the street address is different, the two
523 // addresses are not considered the same.
524 if (Object.values(result).includes("different")) {
528 // If none of the fields in the new address are mergeable, the new address is considered
529 // a duplicate of a local address. Therefore, we don't need to capture this address.
530 const fields = Object.entries(result)
531 .filter(v => ["superset", "similar"].includes(v[1]))
533 if (!fields.length) {
535 "A duplicated address record is found, do not show the prompt"
537 storage.notifyUsed(record.guid);
541 // If the new address is neither a duplicate of the saved address nor a different address.
542 // There must be at least one field we can merge, show the update doorhanger
544 "A mergeable address record is found, show the update prompt"
547 // If one record has fewer mergeable fields compared to another, it suggests greater similarity
548 // to the merged record. In such cases, we opt for the record with the fewest mergeable fields.
549 // TODO: Bug 1830841. Add a testcase
550 if (!mergeableFields.length || mergeableFields > fields.length) {
551 mergeableFields = fields;
552 preserveFields = Object.entries(result)
553 .filter(v => ["same", "subset"].includes(v[1]))
559 // Find a mergeable old record, construct the new record by only copying mergeable fields
560 // from the new address.
562 if (mergeableFields.length) {
563 // TODO: This is only temporarily, should be removed after Bug 1836438 is fixed
564 if (mergeableFields.includes("name")) {
565 mergeableFields.push("given-name", "additional-name", "family-name");
567 mergeableFields.forEach(f => {
568 if (f in newAddress.record) {
569 newRecord[f] = newAddress.record[f];
573 if (preserveFields.includes("name")) {
574 preserveFields.push("given-name", "additional-name", "family-name");
576 preserveFields.forEach(f => {
577 if (f in oldRecord) {
578 newRecord[f] = oldRecord[f];
582 newRecord = newAddress.record;
585 if (!this._shouldShowSaveAddressPrompt(newAddress.record)) {
590 await lazy.FormAutofillPrompter.promptToSaveAddress(
594 { oldRecord, newRecord }
599 async _onCreditCardSubmit(creditCard, browser) {
600 const storage = lazy.gFormAutofillStorage.creditCards;
602 // Make sure record is normalized before comparing with records in the storage
604 storage._normalizeRecord(creditCard.record);
609 // If the record alreay exists in the storage, don't bother showing the prompt
610 const matchRecord = (
611 await storage.getMatchRecords(creditCard.record).next()
614 storage.notifyUsed(matchRecord.guid);
618 // Suppress the pending doorhanger from showing up if user disabled credit card in previous doorhanger.
619 if (!FormAutofill.isAutofillCreditCardsEnabled) {
623 // Overwrite the guid if there is a duplicate
624 const duplicateRecord =
625 (await storage.getDuplicateRecords(creditCard.record).next()).value ?? {};
628 await lazy.FormAutofillPrompter.promptToSaveCreditCard(
632 { oldRecord: duplicateRecord, newRecord: creditCard.record }
637 async _onFormSubmit(data) {
638 let { address, creditCard } = data;
640 let browser = this.manager.browsingContext.top.embedderElement;
642 // Transmit the telemetry immediately in the meantime form submitted, and handle these pending
643 // doorhangers at a later.
647 address.map(addrRecord => this._onAddressSubmit(addrRecord, browser))
650 creditCard.map(ccRecord =>
651 this._onCreditCardSubmit(ccRecord, browser)
655 .map(pendingDoorhangers => {
656 return pendingDoorhangers.filter(
658 !!pendingDoorhanger && typeof pendingDoorhanger == "function"
661 .map(pendingDoorhangers =>
663 for (const showDoorhanger of pendingDoorhangers) {
664 await showDoorhanger();
671 _shouldShowSaveAddressPrompt(record) {
672 if (!FormAutofill.isAutofillAddressesCaptureEnabled) {
676 // Do not save address for regions that we don't support
678 FormAutofill._isAutofillAddressesAvailable == "detect" &&
679 !FormAutofill.isAutofillAddressesAvailableInCountry(record.country)
682 `Do not show the address capture prompt for unsupported regions - ${record.country}`
687 // Display the address capture doorhanger only when the submitted form contains all
688 // the required fields. This approach is implemented to prevent excessive prompting.
689 const requiredFields = FormAutofill.addressCaptureRequiredFields ?? [];
690 if (!requiredFields.every(field => field in record)) {
692 "Do not show the address capture prompt when the submitted form doesn't contain all the required fields"