410 lines
13 KiB
TypeScript
410 lines
13 KiB
TypeScript
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
|
import { ThemeType } from "@bitwarden/common/platform/enums";
|
|
|
|
import { sendExtensionMessage, setElementStyles } from "../../utils";
|
|
import {
|
|
BackgroundPortMessageHandlers,
|
|
AutofillOverlayIframeService as AutofillOverlayIframeServiceInterface,
|
|
AutofillOverlayIframeExtensionMessage,
|
|
} from "../abstractions/autofill-overlay-iframe.service";
|
|
|
|
class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterface {
|
|
private sendExtensionMessage = sendExtensionMessage;
|
|
private port: chrome.runtime.Port | null = null;
|
|
private portKey: string;
|
|
private iframeMutationObserver: MutationObserver;
|
|
private iframe: HTMLIFrameElement;
|
|
private ariaAlertElement: HTMLDivElement;
|
|
private ariaAlertTimeout: number | NodeJS.Timeout;
|
|
private iframeStyles: Partial<CSSStyleDeclaration> = {
|
|
all: "initial",
|
|
position: "fixed",
|
|
display: "block",
|
|
zIndex: "2147483647",
|
|
lineHeight: "0",
|
|
overflow: "hidden",
|
|
transition: "opacity 125ms ease-out 0s",
|
|
visibility: "visible",
|
|
clipPath: "none",
|
|
pointerEvents: "auto",
|
|
margin: "0",
|
|
padding: "0",
|
|
colorScheme: "normal",
|
|
opacity: "0",
|
|
};
|
|
private defaultIframeAttributes: Record<string, string> = {
|
|
src: "",
|
|
title: "",
|
|
allowtransparency: "true",
|
|
tabIndex: "-1",
|
|
};
|
|
private foreignMutationsCount = 0;
|
|
private mutationObserverIterations = 0;
|
|
private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout;
|
|
private readonly backgroundPortMessageHandlers: BackgroundPortMessageHandlers = {
|
|
initAutofillInlineMenuButton: ({ message }) => this.initAutofillInlineMenu(message),
|
|
initAutofillInlineMenuList: ({ message }) => this.initAutofillInlineMenu(message),
|
|
updateIframePosition: ({ message }) => this.updateIframePosition(message.styles),
|
|
updateInlineMenuHidden: ({ message }) => this.updateElementStyles(this.iframe, message.styles),
|
|
updateAutofillInlineMenuColorScheme: () => this.updateAutofillInlineMenuColorScheme(),
|
|
};
|
|
|
|
constructor(
|
|
private shadow: ShadowRoot,
|
|
private portName: string,
|
|
private initStyles: Partial<CSSStyleDeclaration>,
|
|
private iframeTitle: string,
|
|
private ariaAlert?: string,
|
|
) {
|
|
this.iframeMutationObserver = new MutationObserver(this.handleMutations);
|
|
}
|
|
|
|
/**
|
|
* Handles initialization of the iframe which includes applying initial styles
|
|
* to the iframe, setting the source, and adding listener that connects the
|
|
* iframe to the background script each time it loads. Can conditionally
|
|
* create an aria alert element to announce to screen readers when the iframe
|
|
* is loaded. The end result is append to the shadowDOM of the custom element
|
|
* that is declared.
|
|
*/
|
|
initMenuIframe() {
|
|
this.defaultIframeAttributes.src = chrome.runtime.getURL("overlay/menu.html");
|
|
this.defaultIframeAttributes.title = this.iframeTitle;
|
|
|
|
this.iframe = globalThis.document.createElement("iframe");
|
|
this.updateElementStyles(this.iframe, { ...this.iframeStyles, ...this.initStyles });
|
|
for (const [attribute, value] of Object.entries(this.defaultIframeAttributes)) {
|
|
this.iframe.setAttribute(attribute, value);
|
|
}
|
|
this.iframe.addEventListener(EVENTS.LOAD, this.setupPortMessageListener);
|
|
|
|
if (this.ariaAlert) {
|
|
this.createAriaAlertElement(this.ariaAlert);
|
|
}
|
|
|
|
this.shadow.appendChild(this.iframe);
|
|
}
|
|
|
|
/**
|
|
* Creates an aria alert element that is used to announce to screen readers
|
|
* when the iframe is loaded.
|
|
*
|
|
* @param ariaAlertText - Text to announce to screen readers when the iframe is loaded
|
|
*/
|
|
private createAriaAlertElement(ariaAlertText: string) {
|
|
this.ariaAlertElement = globalThis.document.createElement("div");
|
|
this.ariaAlertElement.setAttribute("role", "alert");
|
|
this.ariaAlertElement.setAttribute("aria-live", "polite");
|
|
this.ariaAlertElement.setAttribute("aria-atomic", "true");
|
|
this.updateElementStyles(this.ariaAlertElement, {
|
|
position: "absolute",
|
|
top: "-9999px",
|
|
left: "-9999px",
|
|
width: "1px",
|
|
height: "1px",
|
|
overflow: "hidden",
|
|
opacity: "0",
|
|
pointerEvents: "none",
|
|
});
|
|
this.ariaAlertElement.textContent = ariaAlertText;
|
|
}
|
|
|
|
/**
|
|
* Sets up the port message listener to the extension background script. This
|
|
* listener is used to communicate between the iframe and the background script.
|
|
* This also facilitates announcing to screen readers when the iframe is loaded.
|
|
*/
|
|
private setupPortMessageListener = () => {
|
|
this.port = chrome.runtime.connect({ name: this.portName });
|
|
this.port.onDisconnect.addListener(this.handlePortDisconnect);
|
|
this.port.onMessage.addListener(this.handlePortMessage);
|
|
|
|
this.announceAriaAlert();
|
|
};
|
|
|
|
/**
|
|
* Announces the aria alert element to screen readers when the iframe is loaded.
|
|
*/
|
|
private announceAriaAlert() {
|
|
if (!this.ariaAlertElement) {
|
|
return;
|
|
}
|
|
|
|
this.ariaAlertElement.remove();
|
|
if (this.ariaAlertTimeout) {
|
|
clearTimeout(this.ariaAlertTimeout);
|
|
}
|
|
|
|
this.ariaAlertTimeout = setTimeout(() => this.shadow.appendChild(this.ariaAlertElement), 2000);
|
|
}
|
|
|
|
/**
|
|
* Handles disconnecting the port message listener from the extension background
|
|
* script. This also removes the listener that facilitates announcing to screen
|
|
* readers when the iframe is loaded.
|
|
*
|
|
* @param port - The port that is disconnected
|
|
*/
|
|
private handlePortDisconnect = (port: chrome.runtime.Port) => {
|
|
if (port.name !== this.portName) {
|
|
return;
|
|
}
|
|
|
|
this.updateElementStyles(this.iframe, { opacity: "0", height: "0px", display: "block" });
|
|
this.unobserveIframe();
|
|
this.port?.onMessage.removeListener(this.handlePortMessage);
|
|
this.port?.onDisconnect.removeListener(this.handlePortDisconnect);
|
|
this.port?.disconnect();
|
|
this.port = null;
|
|
};
|
|
|
|
/**
|
|
* Handles messages sent from the extension background script to the iframe.
|
|
* Triggers behavior within the iframe as well as on the custom element that
|
|
* contains the iframe element.
|
|
*
|
|
* @param message
|
|
* @param port
|
|
*/
|
|
private handlePortMessage = (
|
|
message: AutofillOverlayIframeExtensionMessage,
|
|
port: chrome.runtime.Port,
|
|
) => {
|
|
if (port.name !== this.portName) {
|
|
return;
|
|
}
|
|
|
|
if (this.backgroundPortMessageHandlers[message.command]) {
|
|
this.backgroundPortMessageHandlers[message.command]({ message, port });
|
|
return;
|
|
}
|
|
|
|
this.postMessageToIFrame(message);
|
|
};
|
|
|
|
/**
|
|
* Handles the initialization of the autofill overlay. This includes setting
|
|
* the port key and sending a message to the iframe to initialize the overlay.
|
|
*
|
|
* @param message
|
|
* @private
|
|
*/
|
|
private initAutofillInlineMenu(message: AutofillOverlayIframeExtensionMessage) {
|
|
this.portKey = message.portKey;
|
|
if (message.command === "initAutofillInlineMenuList") {
|
|
this.initAutofillInlineMenuList(message);
|
|
return;
|
|
}
|
|
|
|
this.postMessageToIFrame(message);
|
|
}
|
|
|
|
/**
|
|
* Handles initialization of the autofill overlay list. This includes setting
|
|
* the theme and sending a message to the iframe to initialize the overlay.
|
|
*
|
|
* @param message - The message sent from the iframe
|
|
*/
|
|
private initAutofillInlineMenuList(message: AutofillOverlayIframeExtensionMessage) {
|
|
const { theme } = message;
|
|
let borderColor: string;
|
|
let verifiedTheme = theme;
|
|
if (verifiedTheme === ThemeType.System) {
|
|
verifiedTheme = globalThis.matchMedia("(prefers-color-scheme: dark)").matches
|
|
? ThemeType.Dark
|
|
: ThemeType.Light;
|
|
}
|
|
|
|
if (verifiedTheme === ThemeType.Dark) {
|
|
borderColor = "#4c525f";
|
|
}
|
|
if (theme === ThemeType.Nord) {
|
|
borderColor = "#2E3440";
|
|
}
|
|
if (theme === ThemeType.SolarizedDark) {
|
|
borderColor = "#073642";
|
|
}
|
|
if (borderColor) {
|
|
this.updateElementStyles(this.iframe, { borderColor });
|
|
}
|
|
|
|
message.theme = verifiedTheme;
|
|
this.postMessageToIFrame(message);
|
|
}
|
|
|
|
private postMessageToIFrame(message: any) {
|
|
this.iframe.contentWindow?.postMessage({ portKey: this.portKey, ...message }, "*");
|
|
}
|
|
|
|
/**
|
|
* Updates the position of the iframe element. Will also announce
|
|
* to screen readers that the iframe is open.
|
|
*
|
|
* @param position - The position styles to apply to the iframe
|
|
*/
|
|
private updateIframePosition(position: Partial<CSSStyleDeclaration>) {
|
|
if (!globalThis.document.hasFocus()) {
|
|
return;
|
|
}
|
|
|
|
this.updateElementStyles(this.iframe, position);
|
|
setTimeout(() => this.updateElementStyles(this.iframe, { opacity: "1" }), 0);
|
|
this.announceAriaAlert();
|
|
}
|
|
|
|
/**
|
|
* Gets the page color scheme meta tag and sends a message to the iframe
|
|
* to update its color scheme. Will default to "normal" if the meta tag
|
|
* does not exist.
|
|
*/
|
|
private updateAutofillInlineMenuColorScheme() {
|
|
const colorSchemeValue = globalThis.document
|
|
.querySelector("meta[name='color-scheme']")
|
|
?.getAttribute("content");
|
|
|
|
this.postMessageToIFrame({
|
|
command: "updateAutofillInlineMenuColorScheme",
|
|
colorScheme: colorSchemeValue || "normal",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Accepts an element and updates the styles for that element. This method
|
|
* will also unobserve the element if it is the iframe element. This is
|
|
* done to ensure that we do not trigger the mutation observer when we
|
|
* update the styles for the iframe.
|
|
*
|
|
* @param customElement - The element to update the styles for
|
|
* @param styles - The styles to apply to the element
|
|
*/
|
|
private updateElementStyles(customElement: HTMLElement, styles: Partial<CSSStyleDeclaration>) {
|
|
if (!customElement) {
|
|
return;
|
|
}
|
|
|
|
this.unobserveIframe();
|
|
|
|
setElementStyles(customElement, styles, true);
|
|
this.iframeStyles = { ...this.iframeStyles, ...styles };
|
|
|
|
this.observeIframe();
|
|
}
|
|
|
|
/**
|
|
* Handles mutations to the iframe element. The ensures that the iframe
|
|
* element's styles are not modified by a third party source.
|
|
*
|
|
* @param mutations - The mutations to the iframe element
|
|
*/
|
|
private handleMutations = (mutations: MutationRecord[]) => {
|
|
if (this.isTriggeringExcessiveMutationObserverIterations()) {
|
|
return;
|
|
}
|
|
|
|
for (let index = 0; index < mutations.length; index++) {
|
|
const mutation = mutations[index];
|
|
if (mutation.type !== "attributes") {
|
|
continue;
|
|
}
|
|
|
|
const element = mutation.target as HTMLElement;
|
|
if (mutation.attributeName !== "style") {
|
|
this.handleElementAttributeMutation(element);
|
|
|
|
continue;
|
|
}
|
|
|
|
this.iframe.removeAttribute("style");
|
|
this.updateElementStyles(this.iframe, this.iframeStyles);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Triggers a forced closure of the autofill overlay. This is used when the
|
|
* mutation observer is triggered excessively.
|
|
*/
|
|
private forceCloseAutofillInlineMenu() {
|
|
void this.sendExtensionMessage("closeAutofillInlineMenu", { forceClose: true });
|
|
}
|
|
|
|
/**
|
|
* Handles mutations to the iframe element's attributes. This ensures that
|
|
* the iframe element's attributes are not modified by a third party source.
|
|
*
|
|
* @param element - The element to handle attribute mutations for
|
|
*/
|
|
private handleElementAttributeMutation(element: HTMLElement) {
|
|
const attributes = Array.from(element.attributes);
|
|
for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex++) {
|
|
const attribute = attributes[attributeIndex];
|
|
if (attribute.name === "style") {
|
|
continue;
|
|
}
|
|
|
|
if (this.foreignMutationsCount >= 10) {
|
|
this.forceCloseAutofillInlineMenu();
|
|
break;
|
|
}
|
|
|
|
const defaultIframeAttribute = this.defaultIframeAttributes[attribute.name];
|
|
if (!defaultIframeAttribute) {
|
|
this.iframe.removeAttribute(attribute.name);
|
|
this.foreignMutationsCount++;
|
|
continue;
|
|
}
|
|
|
|
if (attribute.value === defaultIframeAttribute) {
|
|
continue;
|
|
}
|
|
|
|
this.iframe.setAttribute(attribute.name, defaultIframeAttribute);
|
|
this.foreignMutationsCount++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Observes the iframe element for mutations to its style attribute.
|
|
*/
|
|
private observeIframe() {
|
|
this.iframeMutationObserver.observe(this.iframe, { attributes: true });
|
|
}
|
|
|
|
/**
|
|
* Unobserves the iframe element for mutations to its style attribute.
|
|
*/
|
|
private unobserveIframe() {
|
|
this.iframeMutationObserver?.disconnect();
|
|
}
|
|
|
|
/**
|
|
* Identifies if the mutation observer is triggering excessive iterations.
|
|
* Will remove the autofill overlay if any set mutation observer is
|
|
* triggering excessive iterations.
|
|
*/
|
|
private isTriggeringExcessiveMutationObserverIterations() {
|
|
const resetCounters = () => {
|
|
this.mutationObserverIterations = 0;
|
|
this.foreignMutationsCount = 0;
|
|
};
|
|
|
|
if (this.mutationObserverIterationsResetTimeout) {
|
|
clearTimeout(this.mutationObserverIterationsResetTimeout);
|
|
}
|
|
|
|
this.mutationObserverIterations++;
|
|
this.mutationObserverIterationsResetTimeout = setTimeout(() => resetCounters(), 2000);
|
|
|
|
if (this.mutationObserverIterations > 20) {
|
|
clearTimeout(this.mutationObserverIterationsResetTimeout);
|
|
resetCounters();
|
|
this.forceCloseAutofillInlineMenu();
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export default AutofillOverlayIframeService;
|