diff options
Diffstat (limited to 'toolkit/actors')
-rw-r--r-- | toolkit/actors/AutoCompleteChild.sys.mjs | 153 | ||||
-rw-r--r-- | toolkit/actors/AutoCompleteParent.sys.mjs | 107 |
2 files changed, 253 insertions, 7 deletions
diff --git a/toolkit/actors/AutoCompleteChild.sys.mjs b/toolkit/actors/AutoCompleteChild.sys.mjs index 3694e497d5..61d48e9124 100644 --- a/toolkit/actors/AutoCompleteChild.sys.mjs +++ b/toolkit/actors/AutoCompleteChild.sys.mjs @@ -13,6 +13,10 @@ ChromeUtils.defineESModuleGetters(lazy, { LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", }); +const gFormFillController = Cc[ + "@mozilla.org/satchel/form-fill-controller;1" +].getService(Ci.nsIFormFillController); + let autoCompleteListeners = new Set(); export class AutoCompleteChild extends JSWindowActorChild { @@ -137,6 +141,7 @@ export class AutoCompleteChild extends JSWindowActorChild { dir, inputElementIdentifier, formOrigin, + actorName: this.lastAutoCompleteProviderName, }); this._input = input; @@ -190,6 +195,154 @@ export class AutoCompleteChild extends JSWindowActorChild { return results; } + + getNoRollupOnEmptySearch(input) { + const providers = this.providersByInput(input); + return Array.from(providers).find(p => p.actorName == "LoginManager"); + } + + // Store the input to interested autocomplete providers mapping + #providersByInput = new WeakMap(); + + // This functions returns the interested providers that have called + // `markAsAutoCompletableField` for the given input and also the hard-coded + // autocomplete providers based on input type. + providersByInput(input) { + const providers = new Set(this.#providersByInput.get(input)); + + if (input.hasBeenTypePassword) { + providers.add( + input.ownerGlobal.windowGlobalChild.getActor("LoginManager") + ); + } else { + // The current design is that FormHisotry doesn't call `markAsAutoCompletable` + // for every eligilbe input. Instead, when FormFillController receives a focus event, + // it would control the <input> if the <input> is eligible to show form history. + // Because of the design, we need to ask FormHistory whether to search for autocomplete entries + // for every startSearch call + providers.add( + input.ownerGlobal.windowGlobalChild.getActor("FormHistory") + ); + } + return providers; + } + + /** + * This API should be used by an autocomplete entry provider to mark an input field + * as eligible for autocomplete for its type. + * When users click on an autocompletable input, we will search autocomplete entries + * from all the providers that have called this API for the given <input>. + * + * An autocomplete provider should be a JSWindowActor and implements the following + * functions: + * - string actorName() + * - bool shouldSearchForAutoComplete(element); + * - jsval getAutoCompleteSearchOption(element); + * - jsval searchResultToAutoCompleteResult(searchString, element, record); + * See `FormAutofillChild` for example + * + * @param input - The HTML <input> element that is considered autocompletable by the + * given provider + * @param provider - A module that provides autocomplete entries for a <input>, for example, + * FormAutofill provides address or credit card autocomplete entries, + * LoginManager provides logins entreis. + */ + markAsAutoCompletableField(input, provider) { + gFormFillController.markAsAutoCompletableField(input); + + let providers = this.#providersByInput.get(input); + if (!providers) { + providers = new Set(); + this.#providersByInput.set(input, providers); + } + providers.add(provider); + } + + // Record the current ongoing search request. This is used by stopSearch + // to prevent notifying the autocomplete controller after receiving search request + // results that were issued prior to the call to stop the search. + #ongoingSearches = new Set(); + + async startSearch(searchString, input, listener) { + // TODO: This should be removed once we implement triggering autocomplete + // from the parent. + this.lastProfileAutoCompleteFocusedInput = input; + + // For all the autocomplete entry providers that previsouly marked + // this <input> as autocompletable, ask the provider whether we should + // search for autocomplete entries in the parent. This is because the current + // design doesn't rely on the provider constantly monitor the <input> and + // then mark/unmark an input. The provider generally calls the + // `markAsAutoCompletbleField` when it sees an <input> is eliglbe for autocomplete. + // Here we ask the provider to exam the <input> more detailedly to see + // whether we need to search for autocomplete entries at the time users + // click on the <input> + const providers = this.providersByInput(input); + const data = Array.from(providers) + .filter(p => p.shouldSearchForAutoComplete(input, searchString)) + .map(p => ({ + actorName: p.actorName, + options: p.getAutoCompleteSearchOption(input, searchString), + })); + + let result = []; + + // We don't return empty result when no provider requests seaching entries in the + // parent because for some special cases, the autocomplete entries are coming + // from the content. For example, <datalist>. + if (data.length) { + const promise = this.sendQuery("AutoComplete:StartSearch", { + searchString, + data, + }); + this.#ongoingSearches.add(promise); + result = await promise.catch(e => { + this.#ongoingSearches.delete(promise); + }); + result ||= []; + + // If the search is stopped, don't report back. + if (!this.#ongoingSearches.delete(promise)) { + return; + } + } + + for (const provider of providers) { + // Search result could be empty. However, an autocomplete provider might + // 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( + searchString, + input, + searchResult + ); + + // 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; + } + } + this.lastProfileAutoCompleteResult = null; + } + + stopSearch() { + 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([ diff --git a/toolkit/actors/AutoCompleteParent.sys.mjs b/toolkit/actors/AutoCompleteParent.sys.mjs index 70cbcea44d..4e0b030211 100644 --- a/toolkit/actors/AutoCompleteParent.sys.mjs +++ b/toolkit/actors/AutoCompleteParent.sys.mjs @@ -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; @@ -376,7 +381,7 @@ export class AutoCompleteParent extends JSWindowActorParent { } } - receiveMessage(message) { + async receiveMessage(message) { let browser = this.browsingContext.top.embedderElement; if ( @@ -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; @@ -433,6 +451,12 @@ export class AutoCompleteParent extends JSWindowActorParent { this.closePopup(); break; } + + case "AutoComplete:StartSearch": { + const { searchString, data } = message.data; + const result = await this.#startSearch(searchString, data); + return Promise.resolve(result); + } } // Returning false to pacify ESLint, but this return value is // ignored by the messaging infrastructure. @@ -493,8 +517,77 @@ export class AutoCompleteParent extends JSWindowActorParent { } } + // This defines the supported autocomplete providers and the prioity to show the autocomplete + // entry. + #AUTOCOMPLETE_PROVIDERS = ["FormAutofill", "LoginManager", "FormHistory"]; + + /** + * Search across multiple module to gather autocomplete entries for a given search string. + * + * @param {string} searchString + * The input string used to query autocomplete entries across different + * autocomplete providers. + * @param {Array<Object>} providers + * An array of objects where each object has a `name` used to identify the actor + * name of the provider and `options` that are passed to the `searchAutoCompleteEntries` + * method of the actor. + * @returns {Array<Object>} An array of results objects with `name` of the provider and `entries` + * that are returned from the provider module's `searchAutoCompleteEntries` method. + */ + async #startSearch(searchString, providers) { + for (const name of this.#AUTOCOMPLETE_PROVIDERS) { + const provider = providers.find(p => p.actorName == name); + if (!provider) { + continue; + } + const { actorName, options } = provider; + const actor = + this.browsingContext.currentWindowGlobal.getActor(actorName); + const entries = await actor?.searchAutoCompleteEntries( + searchString, + options + ); + + // We have not yet supported showing autocomplete entries from multiple providers, + if (entries) { + return [{ actorName, ...entries }]; + } + } + return []; + } + 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. |