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
This commit is contained in:
Dimi 2024-04-29 20:35:04 +00:00
parent 58465b2927
commit a6fc3d1c7d
13 changed files with 224 additions and 124 deletions

View file

@ -141,6 +141,7 @@ export class AutoCompleteChild extends JSWindowActorChild {
dir,
inputElementIdentifier,
formOrigin,
actorName: this.lastAutoCompleteProviderName,
});
this._input = input;
@ -308,7 +309,7 @@ export class AutoCompleteChild extends JSWindowActorChild {
for (const provider of providers) {
// Search result could be empty. However, an autocomplete provider might
// want to show an autoclmplete popup when there is no search result. For example,
// want to show an autocomplete popup when there is no search result. For example,
// <datalist> for FormHisotry, insecure warning for LoginManager.
const searchResult = result.find(r => r.actorName == provider.actorName);
const acResult = provider.searchResultToAutoCompleteResult(
@ -320,6 +321,10 @@ export class AutoCompleteChild extends JSWindowActorChild {
// We have not yet supported showing autocomplete entries from multiple providers,
// Note: The prioty is defined in AutoCompleteParent.
if (acResult) {
// `lastAutoCompleteProviderName` should be removed once we implement
// the mapping of autocomplete entry to provider in the parent process.
this.lastAutoCompleteProviderName = provider.actorName;
this.lastProfileAutoCompleteResult = acResult;
listener.onSearchCompletion(acResult);
return;
@ -332,6 +337,12 @@ export class AutoCompleteChild extends JSWindowActorChild {
this.lastProfileAutoCompleteResult = null;
this.#ongoingSearches.clear();
}
selectEntry() {
// we don't need to pass the selected index to the parent process because
// the selected index is maintained in the parent.
this.sendAsyncMessage("AutoComplete:SelectEntry");
}
}
AutoCompleteChild.prototype.QueryInterface = ChromeUtils.generateQI([

View file

@ -143,9 +143,13 @@ var AutoCompleteResultView = {
this.results = [];
},
setResults(actor, results) {
setResults(actor, results, providerActorName) {
this.currentActor = actor;
this.results = results;
if (providerActorName) {
this.providerActor = actor.manager.getActor(providerActorName);
}
},
};
@ -203,7 +207,7 @@ export class AutoCompleteParent extends JSWindowActorParent {
}
}
showPopupWithResults({ rect, dir, results }) {
showPopupWithResults({ rect, dir, results, actorName }) {
if (!results.length || this.openedPopup) {
// We shouldn't ever be showing an empty popup, and if we
// already have a popup open, the old one needs to close before
@ -237,7 +241,8 @@ export class AutoCompleteParent extends JSWindowActorParent {
);
this.openedPopup.style.direction = dir;
AutoCompleteResultView.setResults(this, results);
AutoCompleteResultView.setResults(this, results, actorName);
this.openedPopup.view = AutoCompleteResultView;
this.openedPopup.selectedIndex = -1;
@ -394,6 +399,13 @@ export class AutoCompleteParent extends JSWindowActorParent {
}
switch (message.name) {
case "AutoComplete:SelectEntry": {
if (this.openedPopup) {
this.autofillProfile(this.openedPopup.selectedIndex);
}
break;
}
case "AutoComplete:SetSelectedIndex": {
let { index } = message.data;
if (this.openedPopup) {
@ -403,8 +415,14 @@ export class AutoCompleteParent extends JSWindowActorParent {
}
case "AutoComplete:MaybeOpenPopup": {
let { results, rect, dir, inputElementIdentifier, formOrigin } =
message.data;
let {
results,
rect,
dir,
inputElementIdentifier,
formOrigin,
actorName,
} = message.data;
if (lazy.DELEGATE_AUTOCOMPLETE) {
lazy.GeckoViewAutocomplete.delegateSelection({
browsingContext: this.browsingContext,
@ -413,7 +431,7 @@ export class AutoCompleteParent extends JSWindowActorParent {
formOrigin,
});
} else {
this.showPopupWithResults({ results, rect, dir });
this.showPopupWithResults({ results, rect, dir, actorName });
this.notifyListeners();
}
break;
@ -437,7 +455,7 @@ export class AutoCompleteParent extends JSWindowActorParent {
case "AutoComplete:StartSearch": {
const { searchString, data } = message.data;
const result = await this.#startSearch(searchString, data);
return result;
return Promise.resolve(result);
}
}
// Returning false to pacify ESLint, but this return value is
@ -540,6 +558,36 @@ export class AutoCompleteParent extends JSWindowActorParent {
stopSearch() {}
previewAutofillProfile(index) {
const actor = AutoCompleteResultView.providerActor;
if (!actor) {
return;
}
// Clear preview when the selected index is not valid
if (index < 0) {
actor.previewFields(null);
return;
}
const result = AutoCompleteResultView.results[index];
actor.previewFields(result);
}
/**
* When a field is autocompleted, fill relevant fields
*/
autofillProfile(index) {
// Find the provider of this autocomplete
const actor = AutoCompleteResultView.providerActor;
if (index < 0 || !actor) {
return;
}
const result = AutoCompleteResultView.results[index];
actor.autofillFields(result);
}
/**
* Sends a message to the browser that is requesting the input
* that the open popup should be focused.

View file

@ -1235,6 +1235,8 @@ nsresult nsAutoCompleteController::EnterMatch(bool aIsPopupSelection,
SetSearchStringInternal(value);
}
popup->SelectEntry();
obsSvc->NotifyObservers(input, "autocomplete-did-enter-text", nullptr);
input->OnTextEntered(aEvent);

View file

@ -92,4 +92,8 @@ interface nsIAutoCompletePopup : nsISupports
*/
void stopSearch();
/**
* Notify the autocomplete popup that an autocomplete entry is selected.
*/
void selectEntry();
};

View file

@ -338,13 +338,9 @@ export const ProfileAutocomplete = {
// The observer notification is for autocomplete in a different process.
break;
}
lazy.FormAutofillContent.autofillPending = true;
Services.obs.notifyObservers(null, "autofill-fill-starting");
await this._fillFromAutocompleteRow(
lazy.FormAutofillContent.activeInput
);
Services.obs.notifyObservers(null, "autofill-fill-complete");
lazy.FormAutofillContent.autofillPending = false;
break;
}
}
@ -415,10 +411,7 @@ export const ProfileAutocomplete = {
);
}
}
return;
}
await lazy.FormAutofillContent.activeHandler.autofillFormFields(profile);
},
_clearProfilePreview() {

View file

@ -99,6 +99,9 @@ const observer = {
* Handles content's interactions for the frame.
*/
export class FormAutofillChild extends JSWindowActorChild {
// Flag indicating whether the form is waiting to be filled by Autofill.
#autofillPending = false;
constructor() {
super();
@ -109,9 +112,6 @@ export class FormAutofillChild extends JSWindowActorChild {
this._hasDOMContentLoadedHandler = false;
this._hasPendingTask = false;
// Flag indicating whether the form is waiting to be filled by Autofill.
this._autofillPending = false;
/**
* @type {FormAutofillFieldDetailsManager} handling state management of current forms and handlers.
*/
@ -478,14 +478,14 @@ export class FormAutofillChild extends JSWindowActorChild {
this.unregisterProgressListener();
}
receiveMessage(message) {
async receiveMessage(message) {
if (!lazy.FormAutofill.isAutofillEnabled) {
return;
}
switch (message.name) {
case "FormAutofill:PreviewProfile": {
this.previewProfile(message.data.selectedIndex);
this.previewProfile(message.data);
break;
}
case "FormAutofill:ClearForm": {
@ -493,7 +493,7 @@ export class FormAutofillChild extends JSWindowActorChild {
break;
}
case "FormAutofill:FillForm": {
this.activeHandler.autofillFormFields(message.data);
await this.autofillFields(message.data);
break;
}
}
@ -615,7 +615,7 @@ export class FormAutofillChild extends JSWindowActorChild {
this.debug("updateActiveElement: checking for popup-on-focus");
// We know this element just received focus. If it's a credit card field,
// open its popup.
if (this._autofillPending) {
if (this.#autofillPending) {
this.debug("updateActiveElement: skipping check; autofill is imminent");
} else if (element.value?.length !== 0) {
this.debug(
@ -636,11 +636,6 @@ export class FormAutofillChild extends JSWindowActorChild {
}
}
set autofillPending(flag) {
this.debug("Setting autofillPending to", flag);
this._autofillPending = flag;
}
clearForm() {
let focusedInput =
this.activeInput ||
@ -670,22 +665,32 @@ export class FormAutofillChild extends JSWindowActorChild {
?.lastProfileAutoCompleteFocusedInput;
}
previewProfile(selectedIndex) {
if (
selectedIndex === -1 ||
!this.activeInput ||
this.lastProfileAutoCompleteResult?.getStyleAt(selectedIndex) !=
"autofill"
) {
lazy.ProfileAutocomplete._clearProfilePreview();
previewProfile(profile) {
if (profile && this.activeSection) {
const adaptedProfile = this.activeSection.getAdaptedProfiles([
profile,
])[0];
this.activeSection.previewFormFields(adaptedProfile);
} else {
lazy.ProfileAutocomplete._previewSelectedProfile(selectedIndex);
this.activeSection.clearPreviewedFormFields();
}
}
async autofillFields(profile) {
this.#autofillPending = true;
Services.obs.notifyObservers(null, "autofill-fill-starting");
try {
Services.obs.notifyObservers(null, "autofill-fill-starting");
await this.activeHandler.autofillFormFields(profile);
Services.obs.notifyObservers(null, "autofill-fill-complete");
} finally {
this.#autofillPending = false;
}
}
onPopupClosed() {
this.debug("Popup has closed.");
lazy.ProfileAutocomplete._clearProfilePreview();
this.activeSection?.clearPreviewedFormFields();
}
onPopupOpened() {

View file

@ -696,4 +696,25 @@ export class FormAutofillParent extends JSWindowActorParent {
return true;
}
previewFields(result) {
try {
const profile =
result.style == "autofill" ? JSON.parse(result.comment) : null;
this.sendAsyncMessage("FormAutofill:PreviewProfile", profile);
} catch (e) {
lazy.log.debug("Fail to get preview profile: ", e.message);
}
}
autofillFields(result) {
if (result.style == "autofill") {
try {
const profile = JSON.parse(result.comment);
this.sendAsyncMessage("FormAutofill:FillForm", profile);
} catch (e) {
lazy.log.debug("Fail to get autofill profile.");
}
}
}
}

View file

@ -413,6 +413,7 @@ export class FormAutofillSection {
profile[`${fieldDetail.fieldName}-formatted`] ||
profile[fieldDetail.fieldName] ||
"";
if (HTMLSelectElement.isInstance(element)) {
// Unlike text input, select element is always previewed even if
// the option is already selected.

View file

@ -159,40 +159,6 @@ const observer = {
loginManagerChild()._onNavigation(window.document);
},
// nsIObserver
observe(subject, topic, _data) {
switch (topic) {
case "autocomplete-did-enter-text": {
let input = subject.QueryInterface(Ci.nsIAutoCompleteInput);
let { selectedIndex } = input.popup;
if (selectedIndex < 0 || selectedIndex >= input.controller.matchCount) {
break;
}
let { focusedInput } = lazy.gFormFillService;
if (focusedInput.nodePrincipal.isNullPrincipal) {
// If we have a null principal then prevent any more password manager code from running and
// incorrectly using the document `location`.
return;
}
let window = focusedInput.ownerGlobal;
let loginManagerChild = LoginManagerChild.forWindow(window);
let style = input.controller.getStyleAt(selectedIndex);
if (style == "login" || style == "loginWithOrigin") {
let details = JSON.parse(
input.controller.getCommentAt(selectedIndex)
);
loginManagerChild.onFieldAutoComplete(focusedInput, details.guid);
} else if (style == "generatedPassword") {
loginManagerChild._filledWithGeneratedPassword(focusedInput);
}
break;
}
}
},
// nsIDOMEventListener
handleEvent(aEvent) {
if (!aEvent.isTrusted) {
@ -437,9 +403,6 @@ const observer = {
},
};
// Add this observer once for the process.
Services.obs.addObserver(observer, "autocomplete-did-enter-text");
/**
* Form scenario defines what can be done with form.
*/
@ -1529,6 +1492,16 @@ export class LoginManagerChild extends JSWindowActorChild {
this.notifyObserversOfFormProcessed(msg.data.formid);
break;
}
case "PasswordManager:fillFields": {
const login = lazy.LoginHelper.vanillaObjectToLogin(msg.data);
this.fillFields(login);
break;
}
case "PasswordManager:fillGeneratedPassword": {
const { focusedInput } = lazy.gFormFillService;
this.filledWithGeneratedPassword(focusedInput);
break;
}
}
return undefined;
@ -2178,7 +2151,7 @@ export class LoginManagerChild extends JSWindowActorChild {
/**
* A username or password was autocompleted into a field.
*/
onFieldAutoComplete(acInputField, loginGUID) {
onFieldAutoComplete(acInputField, login) {
if (!lazy.LoginHelper.enabled) {
return;
}
@ -2193,7 +2166,7 @@ export class LoginManagerChild extends JSWindowActorChild {
}
if (lazy.LoginHelper.isUsernameFieldType(acInputField)) {
this.onUsernameAutocompleted(acInputField, loginGUID);
this.onUsernameAutocompleted(acInputField, [login]);
} else if (acInputField.hasBeenTypePassword) {
// Ensure the field gets re-masked and edits don't overwrite the generated
// password in case a generated password was filled into it previously.
@ -2207,7 +2180,7 @@ export class LoginManagerChild extends JSWindowActorChild {
* A username field was filled or tabbed away from so try fill in the
* associated password in the password field.
*/
onUsernameAutocompleted(acInputField, loginGUID = null) {
async onUsernameAutocompleted(acInputField, loginsFound = null) {
lazy.log(`Autocompleting input field with name: ${acInputField.name}`);
let acForm = lazy.LoginFormFactory.createFromField(acInputField);
@ -2223,43 +2196,49 @@ export class LoginManagerChild extends JSWindowActorChild {
const docState = this.stateForDocument(acInputField.ownerDocument);
let { usernameField, newPasswordField: passwordField } =
docState._getFormFields(acForm, false, recipes);
if (usernameField == acInputField) {
// Fill the form when a password field is present.
if (passwordField) {
this._getLoginDataFromParent(acForm, {
guid: loginGUID,
showPrimaryPassword: false,
})
.then(({ form, loginsFound, recipes }) => {
if (!loginGUID) {
// not an explicit autocomplete menu selection, filter for exact matches only
loginsFound = this._filterForExactFormOriginLogins(
loginsFound,
acForm
);
// filter the list for exact matches with the username
// NOTE: this could be an empty string which is a valid username
let searchString = usernameField.value.toLowerCase();
loginsFound = loginsFound.filter(
l => l.username.toLowerCase() == searchString
);
}
// Ignore the event, it's for some input we don't care about.
if (usernameField != acInputField) {
return;
}
this._fillForm(form, loginsFound, recipes, {
autofillForm: true,
clobberPassword: true,
userTriggered: true,
});
})
.catch(console.error);
// Use `loginGUID !== null` to distinguish whether this is called when the
// field is filled or tabbed away from. For the latter, don't highlight the field.
} else if (loginGUID !== null) {
if (!passwordField) {
// Use `loginsFound !== null` to distinguish whether this is called when the
// field is filled or tabbed away from. For the latter, don't highlight the field.
if (loginsFound !== null) {
LoginFormState._highlightFilledField(usernameField);
}
} else {
// Ignore the event, it's for some input we don't care about.
return;
}
// Fill the form when a password field is present.
if (!loginsFound) {
const loginData = await this._getLoginDataFromParent(acForm, {
showPrimaryPassword: false,
}).catch(console.error);
if (!loginData?.loginsFound.length) {
return;
}
// not an explicit autocomplete menu selection, filter for exact matches only
loginsFound = this._filterForExactFormOriginLogins(
loginData.loginsFound,
acForm
);
// filter the list for exact matches with the username
// NOTE: this could be an empty string which is a valid username
const searchString = usernameField.value.toLowerCase();
loginsFound = loginsFound.filter(
l => l.username.toLowerCase() == searchString
);
recipes = loginData.recipes;
}
this._fillForm(acForm, loginsFound, recipes, {
autofillForm: true,
clobberPassword: true,
userTriggered: true,
});
}
/**
@ -2643,7 +2622,7 @@ export class LoginManagerChild extends JSWindowActorChild {
* field is handled accordingly.
* @param {HTMLInputElement} passwordField
*/
_filledWithGeneratedPassword(passwordField) {
filledWithGeneratedPassword(passwordField) {
LoginFormState._highlightFilledField(passwordField);
this._passwordEditedOrGenerated(passwordField, {
triggeredByFillingGenerated: true,
@ -3120,7 +3099,7 @@ export class LoginManagerChild extends JSWindowActorChild {
}
if (style === "generatedPassword") {
this._filledWithGeneratedPassword(passwordField);
this.filledWithGeneratedPassword(passwordField);
}
lazy.log("_fillForm succeeded");
@ -3367,4 +3346,9 @@ export class LoginManagerChild extends JSWindowActorChild {
#isWebAuthnCredentials(autocompleteInfo) {
return autocompleteInfo.credentialType == "webauthn";
}
fillFields(login) {
let { focusedInput } = lazy.gFormFillService;
this.onFieldAutoComplete(focusedInput, login);
}
}

View file

@ -1549,6 +1549,23 @@ export class LoginManagerParent extends JSWindowActorParent {
async searchAutoCompleteEntries(searchString, data) {
return this.doAutocompleteSearch(data.formOrigin, data);
}
previewFields(_result) {
// Logins do not show previews
}
autofillFields(result) {
if (result.style == "login" || result.style == "loginWithOrigin") {
try {
const profile = JSON.parse(result.comment);
this.sendAsyncMessage("PasswordManager:fillFields", profile.login);
} catch (e) {
lazy.log("Fail to get autofill profile: ", e.message);
}
} else if (result.style == "generatedPassword") {
this.sendAsyncMessage("PasswordManager:fillGeneratedPassword");
}
}
}
LoginManagerParent.SUGGEST_IMPORT_DEBOUNCE_MS = 10000;

View file

@ -146,21 +146,29 @@ Login Manager test: filling generated passwords into confirm password fields
"resetLoginsAndGeneratedPasswords", () => {
LoginTestUtils.clearData();
LoginTestUtils.resetGeneratedPasswordsCache();
return Promise.resolve();
}
);
});
function resetLoginsAndGeneratedPasswords() {
return setupScript.sendAsyncMessage("resetLoginsAndGeneratedPasswords");
return setupScript.sendQuery("resetLoginsAndGeneratedPasswords");
}
async function triggerPasswordGeneration(form) {
await openPopupOn(form.pword);
synthesizeKey("KEY_ArrowDown");
synthesizeKey("KEY_Enter");
const storageAddPromise = promiseStorageChanged(["addLogin"]);
await SimpleTest.promiseWaitForCondition(() => !!form.pword.value, "Wait for generated password to get filled");
await storageAddPromise;
}
add_setup(async () => {
SpecialPowers.pushPrefEnv({"set": [["signon.webauthn.autocomplete", false]]});
})
add_named_task("autocomplete menu contains option to generate password", async () => {
await resetLoginsAndGeneratedPasswords();
const form = setContentForTask(formTemplates.form1);
@ -206,7 +214,7 @@ Login Manager test: filling generated passwords into confirm password fields
await resetLoginsAndGeneratedPasswords();
const form = setContentForTask(formTemplates.form1);
await triggerPasswordGeneration(form);
is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password");
await SimpleTest.promiseWaitForCondition(() => form.pword.value == form.pwordNext.value, "Value of the confirm field has been filled with generated password");
});
add_named_task("password field is not masked initially after password generation", async () => {
@ -270,7 +278,7 @@ Login Manager test: filling generated passwords into confirm password fields
form.pword.blur();
await messageSentPromise;
is(form.pwordNext.value, generatedPassword, "Value of the confirm field still holds the original generated password");
await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == generatedPassword, "Value of the confirm field still holds the original generated password");
ok(form.pwordNext.matches(":autofill"), "Highlight is still applied to password confirmation field");
});
@ -314,21 +322,21 @@ Login Manager test: filling generated passwords into confirm password fields
form.pwordNext.focus()
sendString("edited value");
await triggerPasswordGeneration(form);
is(form.pwordNext.value, "edited value", "Value of the confirm field has been filled with generated password");
await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == "edited value", "Value of the confirm field has been filled with generated password");
});
add_named_task("password confirmation does not get filled with the generated password if its readonly", async () => {
await resetLoginsAndGeneratedPasswords();
const form = setContentForTask(formTemplates.form3);
await triggerPasswordGeneration(form);
is(form.pwordNext.value, "", "Value of the confirm field has been filled with generated password");
await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == "", "Value of the confirm field has been filled with generated password");
});
add_named_task("password confirmation does not get filled with the generated password if its disabled", async () => {
await resetLoginsAndGeneratedPasswords();
const form = setContentForTask(formTemplates.form4);
await triggerPasswordGeneration(form);
is(form.pwordNext.value, "", "Value of the confirm field has been filled with generated password");
await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == "", "Value of the confirm field has been filled with generated password");
});
add_named_task("password confirmation matching autocomplete info gets filled with the generated password", async () => {
@ -336,14 +344,14 @@ Login Manager test: filling generated passwords into confirm password fields
const form = setContentForTask(formTemplates.form5);
await triggerPasswordGeneration(form);
is(form.pwordBetween.value, "", "Value of the between field has not been filled");
is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password");
await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == form.pword.value, "Value of the confirm field has been filled with generated password");
});
add_named_task("password confirmation matching autocomplete info gets ignored if its disabled, even if has autocomplete info", async () => {
await resetLoginsAndGeneratedPasswords();
const form = setContentForTask(formTemplates.form6);
await triggerPasswordGeneration(form);
is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password");
await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == form.pword.value, "Value of the confirm field has been filled with generated password");
is(form.pwordAfter.value, "", "Value of the disabled confirmation field has not been filled");
});
@ -365,7 +373,7 @@ Login Manager test: filling generated passwords into confirm password fields
await resetLoginsAndGeneratedPasswords();
const form = setContentForTask(formTemplates.form9);
await triggerPasswordGeneration(form);
is(form.pwordNext.value, form.pword.value, "Value of the confirm field has been filled with generated password");
await SimpleTest.promiseWaitForCondition(() => form.pwordNext.value == form.pword.value, "Value of the confirm field has been filled with generated password");
});
add_named_task("do not fill third password field after the confirm-password field", async () => {

View file

@ -205,4 +205,12 @@ export class FormHistoryParent extends JSWindowActorParent {
}
entry.totalScore = Math.round(entry.frecency * Math.max(1, boundaryCalc));
}
previewFields(_result) {
// Not implemented
}
autofillFields(_result) {
// Not implemented
}
}

View file

@ -832,9 +832,7 @@
setTimeout(() => {
let selectedIndex = popup ? popup.selectedIndex : -1;
actor.manager
.getActor("FormAutofill")
.sendAsyncMessage("FormAutofill:PreviewProfile", { selectedIndex });
actor.previewAutofillProfile(selectedIndex);
}, 0);
}