/* 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 https://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { AIWindow: "moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs", BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", GetPageContent: "moz-src:///browser/components/aiwindow/models/Tools.sys.mjs", Region: "resource://gre/modules/Region.sys.mjs", NonPrivateTabs: "resource:///modules/OpenTabs.sys.mjs", SmartFormFillController: "moz-src:///browser/components/aiwindow/ui/modules/SmartFormFillController.sys.mjs", SmartFormFillAutocomplete: "moz-src:///browser/components/aiwindow/ui/modules/SmartFormFillAutocomplete.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "console", function () { return console.createInstance({ prefix: "SmartFormFillParent" }); }); XPCOMUtils.defineLazyPreferenceGetter( lazy, "disallowedRegions", "browser.smartwindow.smartformfill.disallowedRegions", "" ); /** @typedef {import("moz-src:///browser/components/aiwindow/ui/modules/SmartFormFillController.sys.mjs").SmartFormFillController} Controller */ /** @typedef {import("moz-src:///browser/components/aiwindow/ui/modules/SmartFormFillDocument.sys.mjs").FormData} FormData */ /** @typedef {import("moz-src:///browser/components/aiwindow/ui/modules/SmartFormFillDocument.sys.mjs").FocusedForm} FocusedForm */ /** @typedef {import("moz-src:///browser/components/aiwindow/models/SmartFormFillModel.sys.mjs").PageInfo} PageInfo */ /** @typedef {import("moz-src:///browser/components/aiwindow/models/SmartFormFillModel.sys.mjs").RelevantTab} RelevantTab */ /** @typedef {import("moz-src:///browser/components/aiwindow/ui/modules/SmartFormFillAutocomplete.sys.mjs").SmartFormFillAutocompleteSource} SmartFormFillAutocompleteSource */ /** * @typedef {"idle" | "loading" | "ready" | "failed"} MetadataStatus */ /** * @typedef {object} FormMetadataState * @property {FormData} formData Latest serialized form data. * @property {MetadataStatus} relevantTabsStatus Relevant-tab request state. * @property {MetadataStatus} classificationStatus Classification request state. * @property {Promise | null} relevantTabsPromise Active relevant-tab * request. * @property {Promise | null} classificationPromise Active classification * request. * @property {number} relevantTabsRevision Revision used to reject stale * relevant-tab responses. * @property {number} classificationRevision Revision used to reject stale * classification responses. */ /** * How much page content text to extract */ const MAX_PAGE_CONTENT_LENGTH = 10000; const METADATA_STATUS = Object.freeze({ IDLE: "idle", LOADING: "loading", READY: "ready", FAILED: "failed", }); /** * Parent actor for SmartFormFill */ export class SmartFormFillParent extends JSWindowActorParent { /** * Controller for the managed document. * * @type {Controller | null} */ #controller; /** * Form associated with the current Smart Form Fill autocomplete entry. * * @type {string | null} */ #autocompleteFormId; /** * IDs of the Smart Windows known during the previous tab event. * * @type {Set} */ #smartWindowIds; /** * Generation of the latest autofill request * * @type {number} */ #autofillGeneration; /** * Metadata request state by stable form ID. * * @type {Map} */ #formMetadataById; /** * Whether the actor has been destroyed. * * @type {boolean} */ #destroyed; /** * Creates the parent actor. */ constructor() { super(); this.#controller = null; this.#autocompleteFormId = null; this.#destroyed = false; this.#smartWindowIds = new Set(); this.#autofillGeneration = 0; this.#formMetadataById = new Map(); } /** * Starts listening for browser tab lifecycle changes. */ actorCreated() { this.#smartWindowIds = this.#getSmartWindowIds(); lazy.NonPrivateTabs.addEventListener("TabChange", this); } /** * Handles browser tab lifecycle changes. * * @param {CustomEvent} event */ handleEvent(event) { if ( event.type != "TabChange" || !event.detail.sourceEvents.some(type => ["TabOpen", "TabClose", "TabAttrModified"].includes(type) ) ) { return; } const currentSmartWindowIds = this.#getSmartWindowIds(); const smartWindowTabChanged = event.detail.windowIds.some( id => currentSmartWindowIds.has(id) || this.#smartWindowIds.has(id) ); this.#smartWindowIds = currentSmartWindowIds; if (!smartWindowTabChanged) { return; } for (const metadata of this.#formMetadataById.values()) { ++metadata.relevantTabsRevision; metadata.relevantTabsPromise = null; metadata.relevantTabsStatus = METADATA_STATUS.IDLE; } this.#controller?.invalidateTabs(); } /** * Generates and applies values for the focused form. * * @returns {Promise} */ async triggerAutofill() { const focusedForm = await this.#getFocusedForm(); if (!focusedForm) { return; } const metadata = this.#getFormMetadataState(focusedForm); if (metadata.classificationStatus === METADATA_STATUS.FAILED) { return; } this.#startFormMetadataRequests(metadata); await Promise.all([ metadata.relevantTabsPromise, metadata.classificationPromise, ]); if ( metadata.relevantTabsStatus !== METADATA_STATUS.READY || metadata.classificationStatus !== METADATA_STATUS.READY || this.#cannotAutofill() ) { return; } const relevantTabs = this.#controller.getRelevantTabsFor(focusedForm.id); // TODO: Present the UI to select relevant tabs from list // of tabs, up to 5 // NOTE: this userSelectedTabs would from the user choosing from UI const userSelectedTabs = relevantTabs; await this.#performAutofill(focusedForm, userSelectedTabs); } /** * Receives messages from the child actor. * * @param {object} param * @param {Array | undefined} param.data Message data. * @param {string} param.name The message type * * @returns {Promise | null | undefined} */ receiveMessage({ data, name }) { switch (name) { case "SmartFormFill:IsSmartWindow": return this.#onIsSmartWindow(); case "SmartFormFill:FormUpdate": return this.#onFormUpdate(data); } return null; } /** * Destroys controller state. */ didDestroy() { lazy.NonPrivateTabs.removeEventListener("TabChange", this); this.#smartWindowIds.clear(); this.#destroyed = true; this.#formMetadataById.clear(); this.#controller?.destroy(); this.#controller = null; } /** * Gets the controller used for demand-driven form metadata. * * @returns {Controller} */ #getController() { if (!this.#controller) { this.#controller = new lazy.SmartFormFillController(this.#getPageInfo()); } return this.#controller; } /** * Gets the currently focused form. * * @returns {Promise} */ #getFocusedForm() { return this.sendQuery("SmartFormFill:GetFocusedForm").catch(error => { if (!this.#destroyed) { lazy.console.error("Could not get focused Smart Form Fill form", error); } return null; }); } /** * Gets cached metadata state for a form. * * @param {FocusedForm} focusedForm Focused form data. * @returns {FormMetadataState} */ #getFormMetadataState(focusedForm) { const formData = { id: focusedForm.id, fields: focusedForm.fields, }; let metadata = this.#formMetadataById.get(focusedForm.id); if (!metadata) { metadata = { formData, relevantTabsStatus: METADATA_STATUS.IDLE, classificationStatus: METADATA_STATUS.IDLE, relevantTabsPromise: null, classificationPromise: null, relevantTabsRevision: 0, classificationRevision: 0, }; this.#formMetadataById.set(focusedForm.id, metadata); } else if (this.#hasFormStructureChanged(metadata.formData, formData)) { this.#invalidateFormMetadata(metadata, formData); } return metadata; } /** * Checks whether fields were added to or removed from a form. * * @param {FormData} previousFormData Previously cached form data. * @param {FormData} formData Current form data. * @returns {boolean} Whether the form field collection changed. */ #hasFormStructureChanged(previousFormData, formData) { if (previousFormData.fields.length !== formData.fields.length) { return true; } const previousFieldIds = new Set( previousFormData.fields.map(({ id }) => id) ); return formData.fields.some(({ id }) => !previousFieldIds.has(id)); } /** * Invalidates cached model metadata after a form structure change. * * @param {FormMetadataState} metadata Metadata to invalidate. * @param {FormData} formData Updated form data. */ #invalidateFormMetadata(metadata, formData) { ++metadata.relevantTabsRevision; ++metadata.classificationRevision; metadata.formData = formData; metadata.relevantTabsStatus = METADATA_STATUS.IDLE; metadata.classificationStatus = METADATA_STATUS.IDLE; metadata.relevantTabsPromise = null; metadata.classificationPromise = null; this.#controller?.invalidateForm(formData.id); } /** * Starts missing metadata requests for a form. * * @param {FormMetadataState} metadata Form metadata state. */ #startFormMetadataRequests(metadata) { if (metadata.relevantTabsStatus === METADATA_STATUS.IDLE) { this.#loadRelevantTabs(metadata); } if (metadata.classificationStatus === METADATA_STATUS.IDLE) { this.#loadFieldClassifications(metadata); } } /** * Requests relevant tabs for a form. * * @param {FormMetadataState} metadata Form metadata state. * @returns {Promise} */ #loadRelevantTabs(metadata) { const revision = metadata.relevantTabsRevision; metadata.relevantTabsStatus = METADATA_STATUS.LOADING; const request = this.#getController() .findRelevantTabs(metadata.formData) .catch(error => { if (revision === metadata.relevantTabsRevision && !this.#destroyed) { lazy.console.error("Could not find relevant tabs", error); } }) .then(() => { if (revision !== metadata.relevantTabsRevision || this.#destroyed) { return; } metadata.relevantTabsStatus = METADATA_STATUS.READY; this.sendAsyncMessage("SmartFormFill:RefreshAutocomplete"); }) .finally(() => { if (revision === metadata.relevantTabsRevision) { metadata.relevantTabsPromise = null; } }); metadata.relevantTabsPromise = request; return request; } /** * Requests field classifications for a form. * * @param {FormMetadataState} metadata Form metadata state. * @returns {Promise} */ #loadFieldClassifications(metadata) { const revision = metadata.classificationRevision; metadata.classificationStatus = METADATA_STATUS.LOADING; const request = this.#getController() .classifyFields(metadata.formData) .then(() => { if (revision === metadata.classificationRevision && !this.#destroyed) { metadata.classificationStatus = METADATA_STATUS.READY; } }) .catch(error => { if (revision !== metadata.classificationRevision || this.#destroyed) { return; } metadata.classificationStatus = METADATA_STATUS.FAILED; lazy.console.error("Could not classify Smart Form Fill fields", error); this.sendAsyncMessage("SmartFormFill:RefreshAutocomplete"); }) .finally(() => { if (revision === metadata.classificationRevision) { metadata.classificationPromise = null; } }); metadata.classificationPromise = request; return request; } /** * Triggers the autofill for the focused form with chosen tabs * * @param {FocusedForm} focusedForm * @param {Array} selectedTabs * * @returns {Promise} */ async #performAutofill(focusedForm, selectedTabs) { const generation = ++this.#autofillGeneration; if (!focusedForm || this.#cannotApplyAutofill(generation)) { return; } const numberOfTabs = selectedTabs.length + 1; const perTabTextCharBudget = Math.ceil( MAX_PAGE_CONTENT_LENGTH / numberOfTabs ); const [pageText, tabContentById] = await Promise.all([ this.#getPageText(this.manager.documentURI.spec, perTabTextCharBudget), this.#getTabsContent(selectedTabs, perTabTextCharBudget), ]); if (this.#cannotApplyAutofill(generation)) { return; } const result = await this.#controller .autofill( focusedForm.id, focusedForm.emptyFieldIds, selectedTabs, tabContentById, pageText ) .catch(error => { if (!this.#destroyed) { lazy.console.error( "Could not generate Smart Form Fill values", error ); } return null; }); if (!result || this.#cannotApplyAutofill(generation)) { return; } this.sendAsyncMessage("SmartFormFill:FillForm", result); } /** * Gets the page content for each tab * * @param {Array} selectedTabs * @param {number} textCharLimitPerTab * * @returns {Promise>} */ async #getTabsContent(selectedTabs, textCharLimitPerTab) { const tabContentById = new Map(); const promises = selectedTabs.map(selectedTab => { const tabData = this.#controller.getRelevantTabData(selectedTab.id); if (tabData) { return this.#getPageText(tabData.url, textCharLimitPerTab).then( tabContent => { tabContentById.set(selectedTab.id, tabContent); } ); } return Promise.resolve(); }); await Promise.allSettled(promises); return tabContentById; } /** * Attempts to extract page content from a url if the tab is found * * @param {string} sourceUrl * @param {number} textCharLimit * * @returns {Promise} */ async #getPageText(sourceUrl, textCharLimit) { let windowGlobal = this.manager; if (windowGlobal.documentURI.spec !== sourceUrl) { const tab = lazy.GetPageContent.getTabWithURL(sourceUrl); windowGlobal = tab?.linkedBrowser.browsingContext?.currentWindowGlobal ?? null; } if (!windowGlobal) { return ""; } try { const pageExtractor = windowGlobal.getActor("PageExtractor"); const extraction = await pageExtractor.getText({ sufficientLength: textCharLimit, removeBoilerplate: false, sourceUrl, }); return extraction?.text ?? ""; } catch (error) { if (!this.#destroyed) { lazy.console.error("Could not extract page text", error); } return ""; } } /** * Gets information about the current page. * * @returns {PageInfo} */ #getPageInfo() { const windowGlobal = this.manager; return { url: windowGlobal.documentURI.spec, title: windowGlobal.documentTitle, }; } /** * Gets the IDs of active Smart Windows. * * @returns {Set} */ #getSmartWindowIds() { return new Set( lazy.BrowserWindowTracker.orderedWindows .filter(window => lazy.AIWindow.isAIWindowActive(window)) .map(window => window.windowGlobalChild.innerWindowId) ); } /** * Checks whether autofill can run. * * @returns {boolean} */ #cannotAutofill() { return this.#destroyed || !this.#controller; } /** * Checks whether autofill results may still be applied. * * @param {number} generation * * @returns {boolean} */ #cannotApplyAutofill(generation) { return this.#cannotAutofill() || generation !== this.#autofillGeneration; } /** * Checks whether this is an active Smart Window in a supported region. * * @returns {Promise} */ async #onIsSmartWindow() { if ( this.#destroyed || !lazy.AIWindow.isAIWindowActive(this.browsingContext.topChromeWindow) ) { return false; } // #isDisallowedRegion reads Region.home synchronously. try { await lazy.Region.init(); } catch (error) { lazy.console.error("Could not initialize Region", error); } return !this.#destroyed && !this.#isDisallowedRegion(); } /** * Checks whether the user's home region is on the Smart Form Fill denylist. * An unknown home region is treated as allowed. * * @returns {boolean} */ #isDisallowedRegion() { const homeRegion = lazy.Region.home?.toUpperCase(); return lazy.disallowedRegions .split(",") .map(region => region.trim().toUpperCase()) .filter(Boolean) .includes(homeRegion); } /** * Message handler for "SmartFormFill:FormUpdate" * * @param {Array} formDataList */ #onFormUpdate(formDataList) { if (this.#destroyed || !Array.isArray(formDataList)) { return; } const formsById = new Map( formDataList.map(formData => [formData.id, formData]) ); for (const [formId, metadata] of this.#formMetadataById) { const formData = formsById.get(formId); if (!formData) { ++metadata.relevantTabsRevision; ++metadata.classificationRevision; this.#controller?.invalidateForm(formId); this.#formMetadataById.delete(formId); continue; } if (this.#hasFormStructureChanged(metadata.formData, formData)) { this.#invalidateFormMetadata(metadata, formData); } } } /* * AutoComplete-related functions */ /** * Gets the Smart Form Fill autocomplete entry for the focused field. * * SmartFormFillAutocomplete calls this when an autocomplete provider * requests Smart Form Fill entries. * * @param {string} _searchString * The current autocomplete search string. Smart Form Fill does not * currently filter its entry using this value. * @param {{ focusElementId?: string }} options * Options supplied by SmartFormFillChild. * @returns {Promise<{ entries: Array } | null>} * An object containing the Smart Form Fill entry, or null when the entry * should not be shown. */ async searchAutoCompleteEntries(_searchString, options) { const focusedForm = await this.#getFocusedForm(); if (!focusedForm) { return null; } const metadata = this.#getFormMetadataState(focusedForm); if (metadata.classificationStatus === METADATA_STATUS.FAILED) { return null; } this.#autocompleteFormId = focusedForm.id; this.#startFormMetadataRequests(metadata); const entries = await lazy.SmartFormFillAutocomplete.createItemsAsync({ sffActor: this, formId: focusedForm.id, focusElementId: options.focusElementId, }); return entries.length ? { entries } : null; } /** * Adds parent-only source metadata after the autocomplete popup opens. */ onAutoCompletePopupOpened() { this.#updateAutoCompletePopupSources(); } /** * Adds parent-only source metadata after an open autocomplete popup updates. */ onAutoCompletePopupUpdated() { this.#updateAutoCompletePopupSources(); } /** * Updates the current Smart Form Fill row with its relevant tab sources. */ #updateAutoCompletePopupSources() { const formId = this.#autocompleteFormId; if (!formId || !this.areRelevantTabsReady(formId)) { return; } lazy.SmartFormFillAutocomplete.updatePopupSources({ browser: this.browsingContext.top.embedderElement, sources: this.getRelevantTabSources(formId), }); } /** * Handles selection of a Smart Form Fill autocomplete action. * * AutoCompleteParent calls this using the action name stored in the selected * entry's comment metadata. * * @param {string} message * The selected autocomplete action name. * @returns {Promise | undefined} * The autofill operation for the primary action, or undefined for an * unsupported action. */ onAutoCompleteEntrySelected(message) { if (message == "SmartFormFill:Start") { return this.triggerAutofill(); } return undefined; } /** * Gets display names for relevant tabs associated with a form. * * @param {string} formId Stable form identifier. * @returns {Array} * Display data for relevant tabs, or an empty array when unavailable. */ getRelevantTabSources(formId) { if (!this.#controller) { return []; } return this.#controller .getRelevantTabsFor(formId) .map(({ id }) => this.#controller.getRelevantTabData(id)) .filter(Boolean) .map(tab => ({ label: tab.title || tab.url, favicon: `page-icon:${tab.url}`, })); } /** * Checks whether relevant-tab selection has completed for a form. * * @param {string} formId Stable form identifier. * @returns {boolean} Whether the relevant-tab result can be displayed. */ areRelevantTabsReady(formId) { return ( this.#formMetadataById.get(formId)?.relevantTabsStatus === METADATA_STATUS.READY ); } /** * Whether Smart Form Fill has tabs available as context sources. * * @type {boolean} */ get hasSourceTabs() { return Boolean(this.#controller?.hasSourceTabs); } }