fune/toolkit/components/formautofill/AutofillProfileAutoComplete.sys.mjs
Dimi a6fc3d1c7d Bug 1893623 - P1. Trigger autofill from the parent process r=credential-management-reviewers,sgalich
Currently, when users autocomplete a field for an address, credit card, or login, Firefox also "autofills"
the relevant fields. Here is a quick summary of how we currently manage this process:

1. Users click on an input field, the autocomplete popup is displayed, and Firefox searches for options
   so users can choose which value to autocomplete.
2. AutoCompleteChild searches for the value to autocomplete based on the type of the input field, along
   with the entire profile. For example, when we autocomplete a cc-number field, we also send cc-name, cc-exp, etc., to the child process.
3. AutoCompleteController autocompletes the focused input.
4. AutoCompleteController notifies the corresponding module, which then autofills the remaining fields.

Currently, step 4 is triggered directly in the child process. This patch moves the logic of step 4 from the
child process to the parent process. This change is a prerequisite for supporting autofill across frames and
will also enable us not to send the entire profile in step 2.

Differential Revision: https://phabricator.services.mozilla.com/D208752
2024-04-29 20:35:04 +00:00

449 lines
14 KiB
JavaScript

/* 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/. */
/**
* Form Autofill content process module.
*/
import {
GenericAutocompleteItem,
sendFillRequestToParent,
} from "resource://gre/modules/FillHelpers.sys.mjs";
/* eslint-disable no-use-before-define */
const Cm = Components.manager;
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AddressResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
ComponentUtils: "resource://gre/modules/ComponentUtils.sys.mjs",
CreditCardResult: "resource://autofill/ProfileAutoCompleteResult.sys.mjs",
FormAutofill: "resource://autofill/FormAutofill.sys.mjs",
FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
FormAutofillContent: "resource://autofill/FormAutofillContent.sys.mjs",
FormScenarios: "resource://gre/modules/FormScenarios.sys.mjs",
InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs",
});
const autocompleteController = Cc[
"@mozilla.org/autocomplete/controller;1"
].getService(Ci.nsIAutoCompleteController);
ChromeUtils.defineLazyGetter(
lazy,
"FIELD_STATES",
() => lazy.FormAutofillUtils.FIELD_STATES
);
function getActorFromWindow(contentWindow, name = "FormAutofill") {
// In unit tests, contentWindow isn't a real window.
if (!contentWindow) {
return null;
}
return contentWindow.windowGlobalChild
? contentWindow.windowGlobalChild.getActor(name)
: null;
}
// Register/unregister a constructor as a factory.
function AutocompleteFactory() {}
AutocompleteFactory.prototype = {
register(targetConstructor) {
let proto = targetConstructor.prototype;
this._classID = proto.classID;
let factory =
lazy.ComponentUtils.generateSingletonFactory(targetConstructor);
this._factory = factory;
let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
registrar.registerFactory(
proto.classID,
proto.classDescription,
proto.contractID,
factory
);
if (proto.classID2) {
this._classID2 = proto.classID2;
registrar.registerFactory(
proto.classID2,
proto.classDescription,
proto.contractID2,
factory
);
}
},
unregister() {
let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
registrar.unregisterFactory(this._classID, this._factory);
if (this._classID2) {
registrar.unregisterFactory(this._classID2, this._factory);
}
this._factory = null;
},
};
/**
* @class
*
* @implements {nsIAutoCompleteSearch}
*/
function AutofillProfileAutoCompleteSearch() {
this.log = lazy.FormAutofill.defineLogGetter(
this,
"AutofillProfileAutoCompleteSearch"
);
}
AutofillProfileAutoCompleteSearch.prototype = {
classID: Components.ID("4f9f1e4c-7f2c-439e-9c9e-566b68bc187d"),
contractID: "@mozilla.org/autocomplete/search;1?name=autofill-profiles",
classDescription: "AutofillProfileAutoCompleteSearch",
QueryInterface: ChromeUtils.generateQI(["nsIAutoCompleteSearch"]),
// Begin nsIAutoCompleteSearch implementation
/**
* Searches for a given string and notifies a listener (either synchronously
* or asynchronously) of the result
*
* @param {string} searchString the string to search for
* @param {string} searchParam
* @param {object} previousResult a previous result to use for faster searchinig
* @param {object} listener the listener to notify when the search is complete
*/
startSearch(searchString, searchParam, previousResult, listener) {
let {
activeInput,
activeSection,
activeFieldDetail,
activeHandler,
savedFieldNames,
} = lazy.FormAutofillContent;
this.forceStop = false;
let isAddressField = lazy.FormAutofillUtils.isAddressField(
activeFieldDetail.fieldName
);
const isCreditCardField = lazy.FormAutofillUtils.isCreditCardField(
activeFieldDetail.fieldName
);
let isInputAutofilled =
activeHandler.getFilledStateByElement(activeInput) ==
lazy.FIELD_STATES.AUTO_FILLED;
let allFieldNames = activeSection.allFieldNames;
let filledRecordGUID = activeSection.filledRecordGUID;
let searchPermitted = isAddressField
? lazy.FormAutofill.isAutofillAddressesEnabled
: lazy.FormAutofill.isAutofillCreditCardsEnabled;
let AutocompleteResult = isAddressField
? lazy.AddressResult
: lazy.CreditCardResult;
let isFormAutofillSearch = true;
let pendingSearchResult = null;
ProfileAutocomplete.lastProfileAutoCompleteFocusedInput = activeInput;
// Fallback to form-history if ...
// - specified autofill feature is pref off.
// - no profile can fill the currently-focused input.
// - the current form has already been populated and the field is not
// an empty credit card field.
// - (address only) less than 3 inputs are covered by all saved fields in the storage.
if (
!searchPermitted ||
!savedFieldNames.has(activeFieldDetail.fieldName) ||
(!isInputAutofilled &&
filledRecordGUID &&
!(isCreditCardField && activeInput.value === "")) ||
(isAddressField &&
allFieldNames.filter(field => savedFieldNames.has(field)).length <
lazy.FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD)
) {
isFormAutofillSearch = false;
if (activeInput.autocomplete == "off") {
// Create a dummy result as an empty search result.
pendingSearchResult = new AutocompleteResult("", "", [], [], {});
} else {
pendingSearchResult = new Promise(resolve => {
let formHistory = Cc[
"@mozilla.org/autocomplete/search;1?name=form-history"
].createInstance(Ci.nsIAutoCompleteSearch);
formHistory.startSearch(searchString, searchParam, previousResult, {
onSearchResult: (_, result) => resolve(result),
});
});
}
} else if (isInputAutofilled) {
pendingSearchResult = new AutocompleteResult(searchString, "", [], [], {
isInputAutofilled,
});
} else {
const data = {
fieldName: activeFieldDetail.fieldName,
searchString,
};
pendingSearchResult = this._getRecords(activeInput, data).then(
({ records, externalEntries }) => {
if (this.forceStop) {
return null;
}
// Sort addresses by timeLastUsed for showing the lastest used address at top.
records.sort((a, b) => b.timeLastUsed - a.timeLastUsed);
let adaptedRecords = activeSection.getAdaptedProfiles(records);
let handler = lazy.FormAutofillContent.activeHandler;
let isSecure = lazy.InsecurePasswordUtils.isFormSecure(handler.form);
const result = new AutocompleteResult(
searchString,
activeFieldDetail.fieldName,
allFieldNames,
adaptedRecords,
{ isSecure, isInputAutofilled }
);
result.externalEntries.push(
...externalEntries.map(
entry =>
new GenericAutocompleteItem(
entry.image,
entry.title,
entry.subtitle,
entry.fillMessageName,
entry.fillMessageData
)
)
);
return result;
}
);
}
Promise.resolve(pendingSearchResult).then(result => {
if (this.forceStop) {
// If we notify the listener the search result when the search is already
// cancelled, it corrupts the internal state of the listener. So we only
// reset the controller's state in this case.
if (isFormAutofillSearch) {
autocompleteController.resetInternalState();
}
return;
}
listener.onSearchResult(this, result);
// Don't save cache results or reset state when returning non-autofill results such as the
// form history fallback above.
if (isFormAutofillSearch) {
ProfileAutocomplete.lastProfileAutoCompleteResult = result;
// Reset AutoCompleteController's state at the end of startSearch to ensure that
// none of form autofill result will be cached in other places and make the
// result out of sync.
autocompleteController.resetInternalState();
} else {
// Clear the cache so that we don't try to autofill from it after falling
// back to form history.
ProfileAutocomplete.lastProfileAutoCompleteResult = null;
}
});
},
/**
* Stops an asynchronous search that is in progress
*/
stopSearch() {
ProfileAutocomplete.lastProfileAutoCompleteResult = null;
this.forceStop = true;
},
/**
* Get the records from parent process for AutoComplete result.
*
* @private
* @param {object} input
* Input element for autocomplete.
* @param {object} data
* Parameters for querying the corresponding result.
* @param {string} data.searchString
* The typed string for filtering out the matched records.
* @param {string} data.fieldName
* The identified field name for the input
* @returns {Promise}
* Promise that resolves when addresses returned from parent process.
*/
_getRecords(input, data) {
if (!input) {
return [];
}
let actor = getActorFromWindow(input.ownerGlobal);
return actor.sendQuery("FormAutofill:GetRecords", {
scenarioName: lazy.FormScenarios.detect({ input }).signUpForm
? "SignUpFormScenario"
: "",
...data,
});
},
};
export const ProfileAutocomplete = {
QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
_registered: false,
_factory: null,
ensureRegistered() {
if (this._registered) {
return;
}
this.log = lazy.FormAutofill.defineLogGetter(this, "ProfileAutocomplete");
this.debug("ensureRegistered");
this._factory = new AutocompleteFactory();
this._factory.register(AutofillProfileAutoCompleteSearch);
this._registered = true;
Services.obs.addObserver(this, "autocomplete-will-enter-text");
this.debug(
"ensureRegistered. Finished with _registered:",
this._registered
);
},
ensureUnregistered() {
if (!this._registered) {
return;
}
this.debug("ensureUnregistered");
this._factory.unregister();
this._factory = null;
this._registered = false;
Services.obs.removeObserver(this, "autocomplete-will-enter-text");
},
async observe(_subject, topic, _data) {
switch (topic) {
case "autocomplete-will-enter-text": {
if (!lazy.FormAutofillContent.activeInput) {
// The observer notification is for autocomplete in a different process.
break;
}
await this._fillFromAutocompleteRow(
lazy.FormAutofillContent.activeInput
);
break;
}
}
},
_getSelectedIndex(contentWindow) {
let actor = getActorFromWindow(contentWindow, "AutoComplete");
if (!actor) {
throw new Error("Invalid autocomplete selectedIndex");
}
return actor.selectedIndex;
},
// TODO: This will be removed after we implement triggering autofill from the parent
get lastProfileAutoCompleteResult() {
return lazy.FormAutofillContent.activeAutofillChild
.lastProfileAutoCompleteResult;
},
set lastProfileAutoCompleteFocusedInput(input) {
if (lazy.FormAutofillContent.activeAutofillChild) {
lazy.FormAutofillContent.activeAutofillChild.lastProfileAutoCompleteFocusedInput =
input;
}
},
get lastProfileAutoCompleteFocusedInput() {
return lazy.FormAutofillContent.activeAutofillChild
?.lastProfileAutoCompleteFocusedInput;
},
async _fillFromAutocompleteRow(focusedInput) {
this.debug("_fillFromAutocompleteRow:", focusedInput);
let formDetails = lazy.FormAutofillContent.activeFormDetails;
if (!formDetails) {
// The observer notification is for a different frame.
return;
}
let selectedIndex = this._getSelectedIndex(focusedInput.ownerGlobal);
const validIndex =
selectedIndex >= 0 &&
selectedIndex < this.lastProfileAutoCompleteResult?.matchCount;
const comment = validIndex
? this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex)
: null;
let profile = JSON.parse(comment);
if (
selectedIndex == -1 ||
!this.lastProfileAutoCompleteResult ||
this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) != "autofill"
) {
if (
focusedInput &&
focusedInput == autocompleteController?.input.focusedInput
) {
if (profile?.fillMessageName == "FormAutofill:ClearForm") {
// The child can do this directly.
getActorFromWindow(focusedInput.ownerGlobal)?.clearForm();
} else {
// Pass focusedInput as both input arguments.
await sendFillRequestToParent(
"FormAutofill",
autocompleteController.input,
comment
);
}
}
}
},
_clearProfilePreview() {
if (
!this.lastProfileAutoCompleteFocusedInput ||
!lazy.FormAutofillContent.activeSection
) {
return;
}
lazy.FormAutofillContent.activeSection.clearPreviewedFormFields();
},
_previewSelectedProfile(selectedIndex) {
if (
!lazy.FormAutofillContent.activeInput ||
!lazy.FormAutofillContent.activeFormDetails
) {
// The observer notification is for a different process/frame.
return;
}
if (
!this.lastProfileAutoCompleteResult ||
this.lastProfileAutoCompleteResult.getStyleAt(selectedIndex) != "autofill"
) {
return;
}
let profile = JSON.parse(
this.lastProfileAutoCompleteResult.getCommentAt(selectedIndex)
);
lazy.FormAutofillContent.activeSection.previewFormFields(profile);
},
};