summaryrefslogtreecommitdiffstats
path: root/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs')
-rw-r--r--toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs773
1 files changed, 773 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs b/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs
new file mode 100644
index 0000000000..6ff96d999e
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginAutoComplete.sys.mjs
@@ -0,0 +1,773 @@
+/* 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/. */
+
+/**
+ * nsIAutoCompleteResult and nsILoginAutoCompleteSearch implementations for saved logins.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { GenericAutocompleteItem } from "resource://gre/modules/FillHelpers.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ InsecurePasswordUtils: "resource://gre/modules/InsecurePasswordUtils.sys.mjs",
+ LoginFormFactory: "resource://gre/modules/LoginFormFactory.sys.mjs",
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+ LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs",
+ NewPasswordModel: "resource://gre/modules/NewPasswordModel.sys.mjs",
+});
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "formFillController",
+ "@mozilla.org/satchel/form-fill-controller;1",
+ Ci.nsIFormFillController
+);
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ return lazy.LoginHelper.createLogger("LoginAutoComplete");
+});
+ChromeUtils.defineLazyGetter(lazy, "passwordMgrBundle", () => {
+ return Services.strings.createBundle(
+ "chrome://passwordmgr/locale/passwordmgr.properties"
+ );
+});
+ChromeUtils.defineLazyGetter(lazy, "dateAndTimeFormatter", () => {
+ return new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ });
+});
+
+function loginSort(formHostPort, a, b) {
+ let maybeHostPortA = lazy.LoginHelper.maybeGetHostPortForURL(a.origin);
+ let maybeHostPortB = lazy.LoginHelper.maybeGetHostPortForURL(b.origin);
+ if (formHostPort == maybeHostPortA && formHostPort != maybeHostPortB) {
+ return -1;
+ }
+ if (formHostPort != maybeHostPortA && formHostPort == maybeHostPortB) {
+ return 1;
+ }
+
+ if (a.httpRealm !== b.httpRealm) {
+ // Sort HTTP auth. logins after form logins for the same origin.
+ if (b.httpRealm === null) {
+ return 1;
+ }
+ if (a.httpRealm === null) {
+ return -1;
+ }
+ }
+
+ let userA = a.username.toLowerCase();
+ let userB = b.username.toLowerCase();
+
+ if (userA < userB) {
+ return -1;
+ }
+
+ if (userA > userB) {
+ return 1;
+ }
+
+ return 0;
+}
+
+function findDuplicates(loginList) {
+ let seen = new Set();
+ let duplicates = new Set();
+ for (let login of loginList) {
+ if (seen.has(login.username)) {
+ duplicates.add(login.username);
+ }
+ seen.add(login.username);
+ }
+ return duplicates;
+}
+
+function getLocalizedString(key, ...formatArgs) {
+ if (formatArgs.length) {
+ return lazy.passwordMgrBundle.formatStringFromName(key, formatArgs);
+ }
+ return lazy.passwordMgrBundle.GetStringFromName(key);
+}
+
+class AutocompleteItem {
+ constructor(style) {
+ this.comment = "";
+ this.style = style;
+ this.value = "";
+ }
+
+ removeFromStorage() {
+ /* Do nothing by default */
+ }
+}
+
+class InsecureLoginFormAutocompleteItem extends AutocompleteItem {
+ constructor() {
+ super("insecureWarning");
+
+ this.label = getLocalizedString(
+ "insecureFieldWarningDescription2",
+ getLocalizedString("insecureFieldWarningLearnMore")
+ );
+ }
+}
+
+class LoginAutocompleteItem extends AutocompleteItem {
+ login;
+ #actor;
+
+ constructor(
+ login,
+ hasBeenTypePassword,
+ duplicateUsernames,
+ actor,
+ isOriginMatched
+ ) {
+ super("loginWithOrigin");
+ this.login = login.QueryInterface(Ci.nsILoginMetaInfo);
+ this.#actor = actor;
+
+ const isDuplicateUsername =
+ login.username && duplicateUsernames.has(login.username);
+
+ let username = login.username
+ ? login.username
+ : getLocalizedString("noUsername");
+
+ // If login is empty or duplicated we want to append a modification date to it.
+ if (!login.username || isDuplicateUsername) {
+ const time = lazy.dateAndTimeFormatter.format(
+ new Date(login.timePasswordChanged)
+ );
+ username = getLocalizedString("loginHostAge", username, time);
+ }
+
+ this.label = username;
+ this.value = hasBeenTypePassword ? login.password : login.username;
+ this.comment = JSON.stringify({
+ guid: login.guid,
+ login, // We have to keep login here to satisfy Android
+ isDuplicateUsername,
+ isOriginMatched,
+ comment:
+ isOriginMatched && login.httpRealm === null
+ ? getLocalizedString("displaySameOrigin")
+ : login.displayOrigin,
+ });
+ this.image = `page-icon:${login.origin}`;
+ }
+
+ removeFromStorage() {
+ if (this.#actor) {
+ let vanilla = lazy.LoginHelper.loginToVanillaObject(this.login);
+ this.#actor.sendAsyncMessage("PasswordManager:removeLogin", {
+ login: vanilla,
+ });
+ } else {
+ Services.logins.removeLogin(this.login);
+ }
+ }
+}
+
+class GeneratedPasswordAutocompleteItem extends AutocompleteItem {
+ constructor(generatedPassword, willAutoSaveGeneratedPassword) {
+ super("generatedPassword");
+
+ this.label = getLocalizedString("useASecurelyGeneratedPassword");
+ this.value = generatedPassword;
+ this.comment = JSON.stringify({
+ generatedPassword,
+ willAutoSaveGeneratedPassword,
+ });
+ this.image = "chrome://browser/skin/login.svg";
+ }
+}
+
+class ImportableLearnMoreAutocompleteItem extends AutocompleteItem {
+ constructor() {
+ super("importableLearnMore");
+ this.comment = JSON.stringify({
+ fillMessageName: "PasswordManager:OpenImportableLearnMore",
+ });
+ }
+}
+
+class ImportableLoginsAutocompleteItem extends AutocompleteItem {
+ #actor;
+
+ constructor(browserId, hostname, actor) {
+ super("importableLogins");
+ this.label = browserId;
+ this.comment = JSON.stringify({
+ hostname,
+ fillMessageName: "PasswordManager:HandleImportable",
+ fillMessageData: {
+ browserId,
+ },
+ });
+ this.#actor = actor;
+
+ // This is sent for every item (re)shown, but the parent will debounce to
+ // reduce the count by 1 total.
+ this.#actor.sendAsyncMessage(
+ "PasswordManager:decreaseSuggestImportCount",
+ 1
+ );
+ }
+
+ removeFromStorage() {
+ this.#actor.sendAsyncMessage(
+ "PasswordManager:decreaseSuggestImportCount",
+ 100
+ );
+ }
+}
+
+class LoginsFooterAutocompleteItem extends AutocompleteItem {
+ constructor(formHostname, telemetryEventData) {
+ super("loginsFooter");
+
+ this.label = getLocalizedString("managePasswords.label");
+
+ // The comment field of `loginsFooter` results have many additional pieces of
+ // information for telemetry purposes. After bug 1555209, this information
+ // can be passed to the parent process outside of nsIAutoCompleteResult APIs
+ // so we won't need this hack.
+ this.comment = JSON.stringify({
+ telemetryEventData,
+ formHostname,
+ fillMessageName: "PasswordManager:OpenPreferences",
+ fillMessageData: {
+ entryPoint: "autocomplete",
+ },
+ });
+ }
+}
+
+// nsIAutoCompleteResult implementation
+export class LoginAutoCompleteResult {
+ #rows = [];
+
+ constructor(
+ aSearchString,
+ matchingLogins,
+ autocompleteItems,
+ formOrigin,
+ {
+ generatedPassword,
+ willAutoSaveGeneratedPassword,
+ importable,
+ isSecure,
+ actor,
+ hasBeenTypePassword,
+ hostname,
+ telemetryEventData,
+ }
+ ) {
+ let hidingFooterOnPWFieldAutoOpened = false;
+ const importableBrowsers =
+ importable?.state === "import" && importable?.browsers;
+
+ function isFooterEnabled() {
+ // We need to check LoginHelper.enabled here since the insecure warning should
+ // appear even if pwmgr is disabled but the footer should never appear in that case.
+ if (
+ !lazy.LoginHelper.showAutoCompleteFooter ||
+ !lazy.LoginHelper.enabled
+ ) {
+ return false;
+ }
+
+ // Don't show the footer on non-empty password fields as it's not providing
+ // value and only adding noise since a password was already filled.
+ if (hasBeenTypePassword && aSearchString && !generatedPassword) {
+ lazy.log.debug("Hiding footer: non-empty password field");
+ return false;
+ }
+
+ if (
+ !autocompleteItems?.length &&
+ !importableBrowsers &&
+ !matchingLogins.length &&
+ !generatedPassword &&
+ hasBeenTypePassword &&
+ lazy.formFillController.passwordPopupAutomaticallyOpened
+ ) {
+ hidingFooterOnPWFieldAutoOpened = true;
+ lazy.log.debug(
+ "Hiding footer: no logins and the popup was opened upon focus of the pw. field"
+ );
+ return false;
+ }
+
+ return true;
+ }
+
+ this.searchString = aSearchString;
+
+ // Insecure field warning comes first.
+ if (!isSecure) {
+ this.#rows.push(new InsecureLoginFormAutocompleteItem());
+ }
+
+ // Saved login items
+ let formHostPort = lazy.LoginHelper.maybeGetHostPortForURL(formOrigin);
+ let logins = matchingLogins.sort(loginSort.bind(null, formHostPort));
+ let duplicateUsernames = findDuplicates(matchingLogins);
+
+ for (let login of logins) {
+ let item = new LoginAutocompleteItem(
+ login,
+ hasBeenTypePassword,
+ duplicateUsernames,
+ actor,
+ lazy.LoginHelper.isOriginMatching(login.origin, formOrigin, {
+ schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
+ })
+ );
+ this.#rows.push(item);
+ }
+
+ // The footer comes last if it's enabled
+ if (isFooterEnabled()) {
+ if (autocompleteItems) {
+ this.#rows.push(
+ ...autocompleteItems.map(
+ item =>
+ new GenericAutocompleteItem(
+ item.image,
+ item.title,
+ item.subtitle,
+ item.fillMessageName,
+ item.fillMessageData
+ )
+ )
+ );
+ }
+
+ if (generatedPassword) {
+ this.#rows.push(
+ new GeneratedPasswordAutocompleteItem(
+ generatedPassword,
+ willAutoSaveGeneratedPassword
+ )
+ );
+ }
+
+ // Suggest importing logins if there are none found.
+ if (!logins.length && importableBrowsers) {
+ this.#rows.push(
+ ...importableBrowsers.map(
+ browserId =>
+ new ImportableLoginsAutocompleteItem(browserId, hostname, actor)
+ )
+ );
+ this.#rows.push(new ImportableLearnMoreAutocompleteItem());
+ }
+
+ // If we have anything in autocomplete, then add "Manage Passwords"
+ this.#rows.push(
+ new LoginsFooterAutocompleteItem(hostname, telemetryEventData)
+ );
+ }
+
+ // Determine the result code and default index.
+ if (this.matchCount > 0) {
+ this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
+ this.defaultIndex = 0;
+ } else if (hidingFooterOnPWFieldAutoOpened) {
+ // We use a failure result so that the empty results aren't re-used for when
+ // the user tries to manually open the popup (we want the footer in that case).
+ this.searchResult = Ci.nsIAutoCompleteResult.RESULT_FAILURE;
+ this.defaultIndex = -1;
+ }
+ }
+
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIAutoCompleteResult",
+ "nsISupportsWeakReference",
+ ]);
+
+ /**
+ * Accessed via .wrappedJSObject
+ * @private
+ */
+ get logins() {
+ return this.#rows
+ .filter(item => item instanceof LoginAutocompleteItem)
+ .map(item => item.login);
+ }
+
+ // Allow autoCompleteSearch to get at the JS object so it can
+ // modify some readonly properties for internal use.
+ get wrappedJSObject() {
+ return this;
+ }
+
+ // Interfaces from idl...
+ searchString = null;
+ searchResult = Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
+ defaultIndex = -1;
+ errorDescription = "";
+
+ get matchCount() {
+ return this.#rows.length;
+ }
+
+ #throwOnBadIndex(index) {
+ if (index < 0 || index >= this.matchCount) {
+ throw new Error("Index out of range.");
+ }
+ }
+
+ getValueAt(index) {
+ this.#throwOnBadIndex(index);
+ return this.#rows[index].value;
+ }
+
+ getLabelAt(index) {
+ this.#throwOnBadIndex(index);
+ return this.#rows[index].label;
+ }
+
+ getCommentAt(index) {
+ this.#throwOnBadIndex(index);
+ return this.#rows[index].comment;
+ }
+
+ getStyleAt(index) {
+ this.#throwOnBadIndex(index);
+ return this.#rows[index].style;
+ }
+
+ getImageAt(index) {
+ this.#throwOnBadIndex(index);
+ return this.#rows[index].image ?? "";
+ }
+
+ getFinalCompleteValueAt(index) {
+ return this.getValueAt(index);
+ }
+
+ isRemovableAt(index) {
+ this.#throwOnBadIndex(index);
+ return true;
+ }
+
+ removeValueAt(index) {
+ this.#throwOnBadIndex(index);
+
+ let [removedItem] = this.#rows.splice(index, 1);
+
+ if (this.defaultIndex > this.#rows.length) {
+ this.defaultIndex--;
+ }
+
+ removedItem.removeFromStorage();
+ }
+}
+
+export class LoginAutoComplete {
+ // HTMLInputElement to number, the element's new-password heuristic confidence score
+ #cachedNewPasswordScore = new WeakMap();
+ #autoCompleteLookupPromise = null;
+ classID = Components.ID("{2bdac17c-53f1-4896-a521-682ccdeef3a8}");
+ QueryInterface = ChromeUtils.generateQI(["nsILoginAutoCompleteSearch"]);
+
+ /**
+ * Yuck. This is called directly by satchel:
+ * nsFormFillController::StartSearch()
+ * [toolkit/components/satchel/nsFormFillController.cpp]
+ *
+ * We really ought to have a simple way for code to register an
+ * auto-complete provider, and not have satchel calling pwmgr directly.
+ *
+ * @param {string} aSearchString The value typed in the field.
+ * @param {nsIAutoCompleteResult} aPreviousResult
+ * @param {HTMLInputElement} aElement
+ * @param {nsIFormAutoCompleteObserver} aCallback
+ */
+ startSearch(aSearchString, aPreviousResult, aElement, aCallback) {
+ let { isNullPrincipal } = aElement.nodePrincipal;
+ if (
+ aElement.nodePrincipal.schemeIs("about") ||
+ aElement.nodePrincipal.isSystemPrincipal
+ ) {
+ // Don't show autocomplete results for about: pages.
+ // XXX: Don't we need to call the callback here?
+ return;
+ }
+
+ let searchStartTimeMS = Services.telemetry.msSystemNow();
+
+ // Show the insecure login warning in the passwords field on null principal documents.
+ // Avoid loading InsecurePasswordUtils.jsm in a sandboxed document (e.g. an ad. frame) if we
+ // already know it has a null principal and will therefore get the insecure autocomplete
+ // treatment.
+ // InsecurePasswordUtils doesn't handle the null principal case as not secure because we don't
+ // want the same treatment:
+ // * The web console warnings will be confusing (as they're primarily about http:) and not very
+ // useful if the developer intentionally sandboxed the document.
+ // * The site identity insecure field warning would require LoginManagerChild being loaded and
+ // listening to some of the DOM events we're ignoring in null principal documents. For memory
+ // reasons it's better to not load LMC at all for these sandboxed frames. Also, if the top-
+ // document is sandboxing a document, it probably doesn't want that sandboxed document to be
+ // able to affect the identity icon in the address bar by adding a password field.
+ let form = lazy.LoginFormFactory.createFromField(aElement);
+ let isSecure =
+ !isNullPrincipal && lazy.InsecurePasswordUtils.isFormSecure(form);
+ let { hasBeenTypePassword } = aElement;
+ let hostname = aElement.ownerDocument.documentURIObject.host;
+ let formOrigin = lazy.LoginHelper.getLoginOrigin(
+ aElement.ownerDocument.documentURI
+ );
+ let loginManagerActor = lazy.LoginManagerChild.forWindow(
+ aElement.ownerGlobal
+ );
+ let completeSearch = async autoCompleteLookupPromise => {
+ // Assign to the member synchronously before awaiting the Promise.
+ this.#autoCompleteLookupPromise = autoCompleteLookupPromise;
+
+ let {
+ generatedPassword,
+ importable,
+ logins,
+ autocompleteItems,
+ willAutoSaveGeneratedPassword,
+ } = await autoCompleteLookupPromise;
+
+ // If the search was canceled before we got our
+ // results, don't bother reporting them.
+ // N.B. This check must occur after the `await` above for it to be
+ // effective.
+ if (this.#autoCompleteLookupPromise !== autoCompleteLookupPromise) {
+ lazy.log.debug("Ignoring result from previous search.");
+ return;
+ }
+
+ let telemetryEventData = {
+ acFieldName: aElement.getAutocompleteInfo().fieldName,
+ hadPrevious: !!aPreviousResult,
+ typeWasPassword: aElement.hasBeenTypePassword,
+ fieldType: aElement.type,
+ searchStartTimeMS,
+ stringLength: aSearchString.length,
+ };
+
+ this.#autoCompleteLookupPromise = null;
+ let results = new LoginAutoCompleteResult(
+ aSearchString,
+ logins,
+ autocompleteItems,
+ formOrigin,
+ {
+ generatedPassword,
+ willAutoSaveGeneratedPassword,
+ importable,
+ actor: loginManagerActor,
+ isSecure,
+ hasBeenTypePassword,
+ hostname,
+ telemetryEventData,
+ }
+ );
+ aCallback.onSearchCompletion(results);
+ };
+
+ if (isNullPrincipal) {
+ // Don't search login storage when the field has a null principal as we don't want to fill
+ // logins for the `location` in this case.
+ completeSearch(Promise.resolve({ logins: [] }));
+ return;
+ }
+
+ if (
+ hasBeenTypePassword &&
+ aSearchString &&
+ !loginManagerActor.isPasswordGenerationForcedOn(aElement)
+ ) {
+ // Return empty result on password fields with password already filled,
+ // unless password generation was forced.
+ completeSearch(Promise.resolve({ logins: [] }));
+ return;
+ }
+
+ if (!lazy.LoginHelper.enabled) {
+ completeSearch(Promise.resolve({ logins: [] }));
+ return;
+ }
+
+ let previousResult;
+ if (aPreviousResult) {
+ previousResult = {
+ searchString: aPreviousResult.searchString,
+ logins: lazy.LoginHelper.loginsToVanillaObjects(
+ aPreviousResult.wrappedJSObject.logins
+ ),
+ };
+ } else {
+ previousResult = null;
+ }
+
+ let acLookupPromise = this.#requestAutoCompleteResultsFromParent({
+ searchString: aSearchString,
+ previousResult,
+ inputElement: aElement,
+ form,
+ hasBeenTypePassword,
+ });
+ completeSearch(acLookupPromise).catch(lazy.log.error.bind(lazy.log));
+ }
+
+ stopSearch() {
+ this.#autoCompleteLookupPromise = null;
+ }
+
+ async #requestAutoCompleteResultsFromParent({
+ searchString,
+ previousResult,
+ inputElement,
+ form,
+ hasBeenTypePassword,
+ }) {
+ let actionOrigin = lazy.LoginHelper.getFormActionOrigin(form);
+ let autocompleteInfo = inputElement.getAutocompleteInfo();
+
+ let loginManagerActor = lazy.LoginManagerChild.forWindow(
+ inputElement.ownerGlobal
+ );
+ let forcePasswordGeneration = false;
+ let isProbablyANewPasswordField = false;
+ if (hasBeenTypePassword) {
+ forcePasswordGeneration =
+ loginManagerActor.isPasswordGenerationForcedOn(inputElement);
+ // Run the Fathom model only if the password field does not have the
+ // autocomplete="new-password" attribute.
+ isProbablyANewPasswordField =
+ autocompleteInfo.fieldName == "new-password" ||
+ this.isProbablyANewPasswordField(inputElement);
+ }
+ const scenario = loginManagerActor.getScenario(inputElement);
+
+ if (lazy.LoginHelper.showAutoCompleteFooter) {
+ gAutoCompleteListener.init();
+ }
+
+ lazy.log.debug("LoginAutoComplete search:", {
+ forcePasswordGeneration,
+ hasBeenTypePassword,
+ isProbablyANewPasswordField,
+ searchStringLength: searchString.length,
+ });
+
+ const result = await loginManagerActor.sendQuery(
+ "PasswordManager:autoCompleteLogins",
+ {
+ actionOrigin,
+ searchString,
+ previousResult,
+ forcePasswordGeneration,
+ hasBeenTypePassword,
+ isProbablyANewPasswordField,
+ scenarioName: scenario?.constructor.name,
+ inputMaxLength: inputElement.maxLength,
+ isWebAuthn: this.#isWebAuthnCredentials(autocompleteInfo),
+ }
+ );
+
+ return {
+ generatedPassword: result.generatedPassword,
+ importable: result.importable,
+ autocompleteItems: result.autocompleteItems,
+ logins: lazy.LoginHelper.vanillaObjectsToLogins(result.logins),
+ willAutoSaveGeneratedPassword: result.willAutoSaveGeneratedPassword,
+ };
+ }
+
+ isProbablyANewPasswordField(inputElement) {
+ const threshold = lazy.LoginHelper.generationConfidenceThreshold;
+ if (threshold == -1) {
+ // Fathom is disabled
+ return false;
+ }
+
+ let score = this.#cachedNewPasswordScore.get(inputElement);
+ if (score) {
+ return score >= threshold;
+ }
+
+ const { rules, type } = lazy.NewPasswordModel;
+ const results = rules.against(inputElement);
+ score = results.get(inputElement).scoreFor(type);
+ this.#cachedNewPasswordScore.set(inputElement, score);
+ return score >= threshold;
+ }
+
+ /**
+ * @param {string} autocompleteInfo
+ * @returns whether the non-autofill credential type (https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#non-autofill-credential-type)
+ * of the input field is "webauthn"
+ */
+ #isWebAuthnCredentials(autocompleteInfo) {
+ return autocompleteInfo.credentialType == "webauthn";
+ }
+}
+
+let gAutoCompleteListener = {
+ added: false,
+ fillRequestId: 0,
+
+ init() {
+ if (!this.added) {
+ Services.obs.addObserver(this, "autocomplete-will-enter-text");
+ this.added = true;
+ }
+ },
+
+ async observe(subject, topic, data) {
+ switch (topic) {
+ case "autocomplete-will-enter-text": {
+ await this.sendFillRequestToLoginManagerParent(subject, data);
+ break;
+ }
+ }
+ },
+
+ async sendFillRequestToLoginManagerParent(input, comment) {
+ if (!comment) {
+ return;
+ }
+
+ if (input != lazy.formFillController.controller.input) {
+ return;
+ }
+
+ const { fillMessageName, fillMessageData } = JSON.parse(comment ?? "{}");
+ if (!fillMessageName) {
+ return;
+ }
+
+ this.fillRequestId++;
+ const fillRequestId = this.fillRequestId;
+ const child = lazy.LoginManagerChild.forWindow(
+ input.focusedInput.ownerGlobal
+ );
+ const value = await child.sendQuery(fillMessageName, fillMessageData ?? {});
+
+ // skip fill if another fill operation started during await
+ if (fillRequestId != this.fillRequestId) {
+ return;
+ }
+
+ if (typeof value !== "string") {
+ return;
+ }
+
+ // If LoginManagerParent returned a string to fill, we must do it here because
+ // nsAutoCompleteController.cpp already finished it's work before we finished await.
+ input.textValue = value;
+ input.selectTextRange(value.length, value.length);
+ },
+};