From 4a71e7a65a7e9ef240ea9fbf72da066f04648d6f Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Mon, 6 Oct 2025 14:59:30 -0400 Subject: [PATCH] create inline menu exclusively on page targeted elements --- .../autofill-overlay-content.service.ts | 6 +- .../collect-autofill-content.service.ts | 1 + .../autofill-overlay-content.service.ts | 13 +++- .../collect-autofill-content.service.ts | 68 +++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts index 56c2d1704d2..3af06a02f1d 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts @@ -36,7 +36,11 @@ export interface AutofillOverlayContentService { setupOverlayListeners( autofillFieldElement: ElementWithOpId, autofillFieldData: AutofillField, - pageDetails: AutofillPageDetails, + pageDetails?: AutofillPageDetails, + ): Promise; + setupOverlayListenersOnQualifiedField( + autofillFieldElement: ElementWithOpId, + autofillFieldData: AutofillField, ): Promise; blurMostRecentlyFocusedField(isClosingInlineMenu?: boolean): void; getOwnedInlineMenuTagNames(): string[]; diff --git a/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts index 6bb77b9e7a4..3c7ad3a135e 100644 --- a/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts @@ -18,6 +18,7 @@ interface CollectAutofillContentService { autofillFormElements: AutofillFormElements; getPageDetails(): Promise; getAutofillFieldElementByOpid(opid: string): HTMLElement | null; + getTargetedFields(): {[key: string]: Element} | null; destroy(): void; } diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 656516d1119..2d4094e0f81 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -197,8 +197,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ async setupOverlayListeners( formFieldElement: ElementWithOpId, autofillFieldData: AutofillField, - pageDetails: AutofillPageDetails, + pageDetails?: AutofillPageDetails, ) { + // This should get targeted fields only + if (formFieldElement && !pageDetails) { + this.formFieldElements.set(formFieldElement, autofillFieldData); + await this.setupFormFieldElementEventListeners(formFieldElement); + await this.triggerFormFieldFocusedAction(formFieldElement); + + return; + } + if ( currentlyInSandboxedIframe() || this.formFieldElements.has(formFieldElement) || @@ -1265,7 +1274,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * @param formFieldElement - The form field element to set up the inline menu on. * @param autofillFieldData - Autofill field data captured from the form field element. */ - private async setupOverlayListenersOnQualifiedField( + async setupOverlayListenersOnQualifiedField( formFieldElement: ElementWithOpId, autofillFieldData: AutofillField, ) { diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 47b1c9ea6df..728b3f85a03 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1,5 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { CipherType } from "@bitwarden/common/vault/enums"; + +import { AutofillFieldQualifierType } from "../enums/autofill-field.enums"; import AutofillField from "../models/autofill-field"; import AutofillForm from "../models/autofill-form"; import AutofillPageDetails from "../models/autofill-page-details"; @@ -35,6 +38,8 @@ import { import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service"; import { DomQueryService } from "./abstractions/dom-query.service"; +type TargetedFields = { [type in AutofillFieldQualifierType]?: Element }; + export class CollectAutofillContentService implements CollectAutofillContentServiceInterface { private readonly sendExtensionMessage = sendExtensionMessage; private readonly getAttributeBoolean = getAttributeBoolean; @@ -44,6 +49,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ private _autofillFormElements: AutofillFormElements = new Map(); private autofillFieldElements: AutofillFieldElements = new Map(); private currentLocationHref = ""; + /** + * A value of `null` indicates the configuration has not been initialized, whereas an empty object indicates no set rules. + */ + private readonly pageTargetingRules: null | { [type in AutofillFieldQualifierType]?: string } = {}; private intersectionObserver: IntersectionObserver; private elementInitializingIntersectionObserver: Set = new Set(); private mutationObserver: MutationObserver; @@ -89,6 +98,33 @@ export class CollectAutofillContentService implements CollectAutofillContentServ // Set up listeners on top-layer candidates that predate Mutation Observer setup this.setupInitialTopLayerListeners(); + const targetedFields = this.getTargetedFields(); + const targetedFieldNames = Object.keys(targetedFields) as AutofillFieldQualifierType[]; + if (targetedFieldNames.length) { + for (const fieldName of targetedFieldNames) { + const fieldNode = targetedFields[fieldName] as ElementWithOpId; + + void this.autofillOverlayContentService.setupOverlayListenersOnQualifiedField( + fieldNode, + { + // @TODO needs an opid and added to the list in order for autofill to work + opid: fieldNode.opid || `targeted_field_${fieldName}`, + elementNumber: 0, // Not relevant when using targeting rules + viewable: true, + htmlID: fieldNode.getAttribute('id'), + htmlName: fieldNode.getAttribute('name'), + htmlClass: fieldNode.getAttribute('class'), + tabindex: fieldNode.getAttribute('tabindex'), + title: fieldNode.getAttribute('title'), + inlineMenuFillType: CipherType.Login, + fieldQualifier: fieldName, + }, + ); + } + + return {} as AutofillPageDetails; + } + if (!this.mutationObserver) { this.setupMutationObserver(); } @@ -124,11 +160,40 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.domRecentlyMutated = false; const pageDetails = this.getFormattedPageDetails(autofillFormsData, autofillFieldsData); + this.setupOverlayListeners(pageDetails); return pageDetails; } + getTargetedFields(): TargetedFields { + if (this.pageTargetingRules) { + const definedTargetingRuleFields = Object.keys( + this.pageTargetingRules, + ) as AutofillFieldQualifierType[]; + + // Note - potential bottleneck at async lookup (alternatively, promise map) + const foundTargetedFields = definedTargetingRuleFields.reduce((foundFields, fieldName) => { + const targetingRule = this.pageTargetingRules[fieldName]; + const fieldMatches = this.domQueryService.queryDeepSelector( + globalThis.document, + targetingRule, + ); + + return fieldMatches.length + ? { + ...foundFields, + [fieldName]: fieldMatches[0], + } + : foundFields; + }, {}); + + return foundTargetedFields; + } + + return {}; + } + /** * Find an AutofillField element by its opid, will only return the first * element if there are multiple elements with the same opid. If no @@ -1248,6 +1313,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ cancelIdleCallbackPolyfill(this.updateAfterMutationIdleCallback); } + // If page targeting rules are present, call `getFieldByQuery` for each field and bypass this.updateAfterMutationIdleCallback = requestIdleCallbackPolyfill( this.getPageDetails.bind(this), { timeout: this.updateAfterMutationTimeout }, @@ -1451,6 +1517,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ * @param pageDetails - The page details to use for the inline menu listeners */ private setupOverlayListeners(pageDetails: AutofillPageDetails) { + // if there are targeted rules for the page, get the nodes with those rules, and if successful, pass those to `autofillOverlayContentService.setupOverlayListeners` + if (this.autofillOverlayContentService) { this.autofillFieldElements.forEach((autofillField, formFieldElement) => { this.setupOverlayOnField(formFieldElement, autofillField, pageDetails);