diff --git a/src/background/services/background.ts b/src/background/services/background.ts index f5b9fbad..d4e1ec03 100644 --- a/src/background/services/background.ts +++ b/src/background/services/background.ts @@ -185,7 +185,7 @@ export class Background { // endregion // region Content - case 'CHECK_WALLET_ADDRESS_URL': + case 'GET_WALLET_ADDRESS_INFO': return success( await getWalletInformation(message.payload.walletAddressUrl), ); diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 2de577cb..2c23856e 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -52,7 +52,7 @@ export class MonetizationService { } async startPaymentSession( - payload: StartMonetizationPayload[], + payload: StartMonetizationPayload, sender: Runtime.MessageSender, ) { if (!payload.length) { @@ -146,7 +146,7 @@ export class MonetizationService { } async stopPaymentSession( - payload: StopMonetizationPayload[], + payload: StopMonetizationPayload, sender: Runtime.MessageSender, ) { let needsAdjustAmount = false; @@ -191,7 +191,7 @@ export class MonetizationService { } async resumePaymentSession( - payload: ResumeMonetizationPayload[], + payload: ResumeMonetizationPayload, sender: Runtime.MessageSender, ) { const tabId = getTabId(sender); @@ -243,8 +243,13 @@ export class MonetizationService { async toggleWM() { const { enabled } = await this.storage.get(['enabled']); - await this.storage.set({ enabled: !enabled }); - await this.message.sendToActiveTab('EMIT_TOGGLE_WM', { enabled: !enabled }); + const nowEnabled = !enabled; + await this.storage.set({ enabled: nowEnabled }); + if (nowEnabled) { + await this.resumePaymentSessionActiveTab(); + } else { + this.stopAllSessions(); + } } async pay(amount: string) { diff --git a/src/content/container.ts b/src/content/container.ts index 35969553..c5eaf2d8 100644 --- a/src/content/container.ts +++ b/src/content/container.ts @@ -2,7 +2,7 @@ import { asClass, asValue, createContainer, InjectionMode } from 'awilix'; import browser, { type Browser } from 'webextension-polyfill'; import { createLogger, Logger } from '@/shared/logger'; import { ContentScript } from './services/contentScript'; -import { MonetizationTagManager } from './services/monetizationTagManager'; +import { MonetizationLinkManager } from './services/monetizationLinkManager'; import { LOG_LEVEL } from '@/shared/defines'; import { FrameManager } from './services/frameManager'; import { @@ -16,7 +16,7 @@ export interface Cradle { document: Document; window: Window; message: MessageManager; - monetizationTagManager: MonetizationTagManager; + monetizationLinkManager: MonetizationLinkManager; frameManager: FrameManager; contentScript: ContentScript; } @@ -39,7 +39,7 @@ export const configureContainer = () => { .inject(() => ({ logger: logger.getLogger('content-script:frameManager'), })), - monetizationTagManager: asClass(MonetizationTagManager) + monetizationLinkManager: asClass(MonetizationLinkManager) .singleton() .inject(() => ({ logger: logger.getLogger('content-script:tagManager'), diff --git a/src/content/messages.ts b/src/content/messages.ts index 4eb30d26..c2657404 100644 --- a/src/content/messages.ts +++ b/src/content/messages.ts @@ -1,9 +1,23 @@ -export enum ContentToContentAction { - INITIALIZE_IFRAME = 'INITIALIZE_IFRAME', - IS_MONETIZATION_ALLOWED_ON_START = 'IS_MONETIZATION_ALLOWED_ON_START', - IS_MONETIZATION_ALLOWED_ON_RESUME = 'IS_MONETIZATION_ALLOWED_ON_RESUME', - IS_MONETIZATION_ALLOWED_ON_STOP = 'IS_MONETIZATION_ALLOWED_ON_STOP', - START_MONETIZATION = 'START_MONETIZATION', - STOP_MONETIZATION = 'STOP_MONETIZATION', - RESUME_MONETIZATION = 'RESUME_MONETIZATION', +import type { + ResumeMonetizationPayload, + StartMonetizationPayload, + StopMonetizationPayload, +} from '@/shared/messages'; + +export interface ContentToContentMessageMap { + INITIALIZE_IFRAME: void; + IS_MONETIZATION_ALLOWED_ON_START: StartMonetizationPayload; + IS_MONETIZATION_ALLOWED_ON_RESUME: ResumeMonetizationPayload; + IS_MONETIZATION_ALLOWED_ON_STOP: StopMonetizationPayload; + START_MONETIZATION: StartMonetizationPayload; + STOP_MONETIZATION: StopMonetizationPayload; + RESUME_MONETIZATION: ResumeMonetizationPayload; } + +export type ContentToContentMessage = { + [K in keyof ContentToContentMessageMap]: { + message: K; + id: string; + payload: ContentToContentMessageMap[K]; + }; +}[keyof ContentToContentMessageMap]; diff --git a/src/content/services/contentScript.ts b/src/content/services/contentScript.ts index 84a36d94..85466344 100644 --- a/src/content/services/contentScript.ts +++ b/src/content/services/contentScript.ts @@ -1,12 +1,12 @@ import type { ToContentMessage } from '@/shared/messages'; -import { failure } from '@/shared/helpers'; import type { Cradle } from '@/content/container'; +import { failure } from '@/shared/helpers'; export class ContentScript { private browser: Cradle['browser']; private window: Cradle['window']; private logger: Cradle['logger']; - private monetizationTagManager: Cradle['monetizationTagManager']; + private monetizationLinkManager: Cradle['monetizationLinkManager']; private frameManager: Cradle['frameManager']; private isFirstLevelFrame: boolean; @@ -16,14 +16,14 @@ export class ContentScript { browser, window, logger, - monetizationTagManager, + monetizationLinkManager, frameManager, }: Cradle) { Object.assign(this, { browser, window, logger, - monetizationTagManager, + monetizationLinkManager, frameManager, }); @@ -40,7 +40,7 @@ export class ContentScript { if (this.isTopFrame) this.frameManager.start(); - this.monetizationTagManager.start(); + this.monetizationLinkManager.start(); } } @@ -50,16 +50,10 @@ export class ContentScript { try { switch (message.action) { case 'MONETIZATION_EVENT': - this.monetizationTagManager.dispatchMonetizationEvent( + this.monetizationLinkManager.dispatchMonetizationEvent( message.payload, ); return; - - case 'EMIT_TOGGLE_WM': - this.monetizationTagManager.toggleWM(message.payload); - - return; - default: return; } diff --git a/src/content/services/frameManager.ts b/src/content/services/frameManager.ts index 61b0552e..68b5835e 100644 --- a/src/content/services/frameManager.ts +++ b/src/content/services/frameManager.ts @@ -1,11 +1,17 @@ -import { ContentToContentAction } from '../messages'; +import type { ContentToContentMessage } from '../messages'; import type { - ResumeMonetizationPayload, - StartMonetizationPayload, + ResumeMonetizationPayloadEntry, + StartMonetizationPayloadEntry, StopMonetizationPayload, } from '@/shared/messages'; import type { Cradle } from '@/content/container'; +const HANDLED_MESSAGES: ContentToContentMessage['message'][] = [ + 'INITIALIZE_IFRAME', + 'IS_MONETIZATION_ALLOWED_ON_START', + 'IS_MONETIZATION_ALLOWED_ON_RESUME', +]; + export class FrameManager { private window: Cradle['window']; private document: Cradle['document']; @@ -103,7 +109,7 @@ export class FrameManager { const frameDetails = this.frames.get(frame); - const stopMonetizationTags: StopMonetizationPayload[] = + const stopMonetizationTags: StopMonetizationPayload = frameDetails?.requestIds.map((requestId) => ({ requestId, intent: 'remove', @@ -179,18 +185,13 @@ export class FrameManager { private bindMessageHandler() { this.window.addEventListener( 'message', - (event: any) => { + (event: MessageEvent) => { const { message, payload, id } = event.data; - if ( - ![ - ContentToContentAction.INITIALIZE_IFRAME, - ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_START, - ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_RESUME, - ].includes(message) - ) { + if (!HANDLED_MESSAGES.includes(message)) { return; } - const frame = this.findIframe(event.source); + const eventSource = event.source as Window; + const frame = this.findIframe(eventSource); if (!frame) { event.stopPropagation(); return; @@ -199,7 +200,7 @@ export class FrameManager { if (event.origin === this.window.location.href) return; switch (message) { - case ContentToContentAction.INITIALIZE_IFRAME: + case 'INITIALIZE_IFRAME': event.stopPropagation(); this.frames.set(frame, { frameId: id, @@ -207,42 +208,34 @@ export class FrameManager { }); return; - case ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_START: + case 'IS_MONETIZATION_ALLOWED_ON_START': event.stopPropagation(); if (frame.allow === 'monetization') { this.frames.set(frame, { frameId: id, requestIds: payload.map( - (p: StartMonetizationPayload) => p.requestId, + (p: StartMonetizationPayloadEntry) => p.requestId, ), }); - event.source.postMessage( - { - message: ContentToContentAction.START_MONETIZATION, - id, - payload, - }, + eventSource.postMessage( + { message: 'START_MONETIZATION', id, payload }, '*', ); } return; - case ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_RESUME: + case 'IS_MONETIZATION_ALLOWED_ON_RESUME': event.stopPropagation(); if (frame.allow === 'monetization') { this.frames.set(frame, { frameId: id, requestIds: payload.map( - (p: ResumeMonetizationPayload) => p.requestId, + (p: ResumeMonetizationPayloadEntry) => p.requestId, ), }); - event.source.postMessage( - { - message: ContentToContentAction.RESUME_MONETIZATION, - id, - payload, - }, + eventSource.postMessage( + { message: 'RESUME_MONETIZATION', id, payload }, '*', ); } diff --git a/src/content/services/monetizationLinkManager.ts b/src/content/services/monetizationLinkManager.ts new file mode 100644 index 00000000..4a4770bb --- /dev/null +++ b/src/content/services/monetizationLinkManager.ts @@ -0,0 +1,520 @@ +import { EventEmitter } from 'events'; +import { isNotNull } from '@/shared/helpers'; +import { mozClone, WalletAddressFormatError } from '../utils'; +import type { WalletAddress } from '@interledger/open-payments/dist/types'; +import type { + MonetizationEventPayload, + ResumeMonetizationPayload, + StartMonetizationPayload, + StopMonetizationPayload, + StopMonetizationPayloadEntry, +} from '@/shared/messages'; +import type { Cradle } from '@/content/container'; +import type { ContentToContentMessage } from '../messages'; + +export class MonetizationLinkManager extends EventEmitter { + private window: Cradle['window']; + private document: Cradle['document']; + private logger: Cradle['logger']; + private message: Cradle['message']; + + private isTopFrame: boolean; + private isFirstLevelFrame: boolean; + private documentObserver: MutationObserver; + private monetizationLinkAttrObserver: MutationObserver; + private id: string; + // only entries corresponding to valid wallet addresses are here + private monetizationLinks = new Map< + HTMLLinkElement, + { walletAddress: WalletAddress; requestId: string } + >(); + + constructor({ window, document, logger, message }: Cradle) { + super(); + Object.assign(this, { + window, + document, + logger, + message, + }); + + this.documentObserver = new MutationObserver((records) => + this.onWholeDocumentObserved(records), + ); + + this.monetizationLinkAttrObserver = new MutationObserver((records) => + this.onLinkAttrChange(records), + ); + + this.isTopFrame = window === window.top; + this.isFirstLevelFrame = window.parent === window.top; + this.id = crypto.randomUUID(); + } + + start(): void { + const isDocumentReady = () => { + const doc = this.document; + return ( + (doc.readyState === 'interactive' || doc.readyState === 'complete') && + doc.visibilityState === 'visible' + ); + }; + + if (isDocumentReady()) { + void this.run(); + return; + } + + document.addEventListener( + 'readystatechange', + () => { + if (isDocumentReady()) { + void this.run(); + } else { + document.addEventListener( + 'visibilitychange', + () => { + if (isDocumentReady()) { + void this.run(); + } + }, + { once: true }, + ); + } + }, + { once: true }, + ); + } + + end() { + this.documentObserver.disconnect(); + this.monetizationLinkAttrObserver.disconnect(); + this.monetizationLinks.clear(); + this.document.removeEventListener( + 'visibilitychange', + this.onDocumentVisibilityChange, + ); + this.window.removeEventListener('message', this.onWindowMessage); + } + + /** + * Check if iframe or not + */ + private async run() { + this.document.addEventListener( + 'visibilitychange', + this.onDocumentVisibilityChange, + ); + + if (!this.isTopFrame && this.isFirstLevelFrame) { + this.window.addEventListener('message', this.onWindowMessage); + this.postMessage('INITIALIZE_IFRAME', undefined); + } + + this.document + .querySelectorAll('[onmonetization]') + .forEach((node) => { + this.dispatchOnMonetizationAttrChangedEvent(node); + }); + + this.documentObserver.observe(this.document, { + subtree: true, + childList: true, + attributeFilter: ['onmonetization'], + }); + + const monetizationLinks = this.getMonetizationLinkTags(); + + for (const link of monetizationLinks) { + this.observeLinkAttrs(link); + } + + const validLinks = ( + await Promise.all(monetizationLinks.map((elem) => this.checkLink(elem))) + ).filter(isNotNull); + + for (const { link, details } of validLinks) { + this.monetizationLinks.set(link, details); + } + + await this.sendStartMonetization(validLinks.map((e) => e.details)); + } + + private onWindowMessage = (event: MessageEvent) => { + const { message, id, payload } = event.data; + + if (event.origin === window.location.href || id !== this.id) return; + + switch (message) { + case 'START_MONETIZATION': + return void this.message.send('START_MONETIZATION', payload); + case 'RESUME_MONETIZATION': + return void this.message.send('RESUME_MONETIZATION', payload); + default: + return; + } + }; + + private getMonetizationLinkTags(): HTMLLinkElement[] { + if (this.isTopFrame) { + return Array.from( + this.document.querySelectorAll( + 'link[rel="monetization"]', + ), + ); + } else { + const monetizationTag = this.document.querySelector( + 'head link[rel="monetization"]', + ); + return monetizationTag ? [monetizationTag] : []; + } + } + + /** @throws never throws */ + private async checkLink(link: HTMLLinkElement) { + if (!(link instanceof HTMLLinkElement && link.rel === 'monetization')) { + return null; + } + if (link.hasAttribute('disabled')) { + return null; + } + + const walletAddress = await this.validateLink(link); + if (!walletAddress) { + return null; + } + + return { + link, + details: { + requestId: crypto.randomUUID(), + walletAddress: walletAddress, + }, + }; + } + + /** @throws never throws */ + private async validateLink( + link: HTMLLinkElement, + ): Promise { + const walletAddressUrl = link.href.trim(); + try { + this.checkHrefFormat(walletAddressUrl); + const response = await this.message.send('GET_WALLET_ADDRESS_INFO', { + walletAddressUrl, + }); + + if (response.success === false) { + throw new Error( + `Could not retrieve wallet address information for ${JSON.stringify(walletAddressUrl)}.`, + ); + } + + this.dispatchLoadEvent(link); + return response.payload; + } catch (e) { + this.logger.error(e); + this.dispatchErrorEvent(link); + return null; + } + } + + private checkHrefFormat(href: string): void { + let url: URL; + try { + url = new URL(href); + if (url.protocol !== 'https:') { + throw new WalletAddressFormatError( + `Wallet address URL must be specified as a fully resolved https:// url, ` + + `got ${JSON.stringify(href)} `, + ); + } + } catch (e) { + if (e instanceof WalletAddressFormatError) { + throw e; + } + throw new WalletAddressFormatError( + `Invalid wallet address URL: ${JSON.stringify(href)}`, + ); + } + + const { hash, search, port, username, password } = url; + + if (hash || search || port || username || password) { + throw new WalletAddressFormatError( + `Wallet address URL must not contain query/fragment/port/username/password elements. Received: ${JSON.stringify({ hash, search, port, username, password })}`, + ); + } + } + + private observeLinkAttrs(link: HTMLLinkElement) { + this.monetizationLinkAttrObserver.observe(link, { + childList: false, + attributeOldValue: true, + attributeFilter: ['href', 'disabled', 'rel', 'crossorigin', 'type'], + }); + } + + private dispatchLoadEvent(tag: HTMLLinkElement) { + tag.dispatchEvent(new Event('load')); + } + + private dispatchErrorEvent(tag: HTMLLinkElement) { + tag.dispatchEvent(new Event('error')); + } + + public dispatchMonetizationEvent({ + requestId, + details, + }: MonetizationEventPayload) { + for (const [tag, tagDetails] of this.monetizationLinks) { + if (tagDetails.requestId !== requestId) continue; + + tag.dispatchEvent( + new CustomEvent('__wm_ext_monetization', { + detail: mozClone(details, this.document), + bubbles: true, + }), + ); + break; + } + } + + private dispatchOnMonetizationAttrChangedEvent( + node: HTMLElement, + { changeDetected = false } = {}, + ) { + const attribute = node.getAttribute('onmonetization'); + if (!attribute && !changeDetected) return; + + const customEvent = new CustomEvent('__wm_ext_onmonetization_attr_change', { + bubbles: true, + detail: mozClone({ attribute }, this.document), + }); + node.dispatchEvent(customEvent); + } + + private async stopMonetization() { + const payload: StopMonetizationPayload = [ + ...this.monetizationLinks.values(), + ].map(({ requestId }) => ({ requestId })); + + await this.sendStopMonetization(payload); + } + + private async resumeMonetization() { + const payload: ResumeMonetizationPayload = [ + ...this.monetizationLinks.values(), + ].map(({ requestId }) => ({ requestId })); + + await this.sendResumeMonetization(payload); + } + + private async sendStartMonetization( + payload: StartMonetizationPayload, + onlyToTopIframe = false, + ) { + if (!payload.length) return; + + if (this.isTopFrame) { + await this.message.send('START_MONETIZATION', payload); + } else if (this.isFirstLevelFrame && !onlyToTopIframe) { + this.postMessage('IS_MONETIZATION_ALLOWED_ON_START', payload); + } + } + + private async sendStopMonetization(payload: StopMonetizationPayload) { + if (!payload.length) return; + await this.message.send('STOP_MONETIZATION', payload); + } + + private async sendResumeMonetization( + payload: ResumeMonetizationPayload, + onlyToTopIframe = false, + ) { + if (!payload.length) return; + + if (this.isTopFrame) { + await this.message.send('RESUME_MONETIZATION', payload); + } else if (this.isFirstLevelFrame && !onlyToTopIframe) { + this.postMessage('IS_MONETIZATION_ALLOWED_ON_RESUME', payload); + } + } + + private onDocumentVisibilityChange = async () => { + if (this.document.visibilityState === 'visible') { + await this.resumeMonetization(); + } else { + await this.stopMonetization(); + } + }; + + private async onWholeDocumentObserved(records: MutationRecord[]) { + const stopMonetizationPayload: StopMonetizationPayload = []; + + for (const record of records) { + if (record.type === 'childList') { + record.removedNodes.forEach(async (node) => { + if (!(node instanceof HTMLLinkElement)) return; + const payloadEntry = this.onRemovedLink(node); + stopMonetizationPayload.push(payloadEntry); + }); + } + } + + await this.sendStopMonetization(stopMonetizationPayload); + + if (this.isTopFrame) { + const addedNodes = records + .filter((e) => e.type === 'childList') + .flatMap((e) => [...e.addedNodes]); + const allAddedLinkTags = await Promise.all( + addedNodes.map((node) => this.onAddedNode(node)), + ); + const startMonetizationPayload = allAddedLinkTags + .filter(isNotNull) + .map(({ details }) => details); + + void this.sendStartMonetization(startMonetizationPayload); + } + + for (const record of records) { + if ( + record.type === 'attributes' && + record.target instanceof HTMLElement && + record.attributeName === 'onmonetization' + ) { + this.dispatchOnMonetizationAttrChangedEvent(record.target, { + changeDetected: true, + }); + } + } + } + + private postMessage( + message: K, + payload: Extract['payload'], + ) { + this.window.parent.postMessage({ message, id: this.id, payload }, '*'); + } + + private async onLinkAttrChange(records: MutationRecord[]) { + const handledTags = new Set(); + const startMonetizationPayload: StartMonetizationPayload = []; + const stopMonetizationPayload: StopMonetizationPayload = []; + + // Check for a non specified link with the type now specified and + // just treat it as a newly seen, monetization tag + for (const record of records) { + const target = record.target as HTMLLinkElement; + if (handledTags.has(target)) { + continue; + } + + const hasTarget = this.monetizationLinks.has(target); + const linkRelSpecified = + target instanceof HTMLLinkElement && target.rel === 'monetization'; + // this will also handle the case of a @disabled tag that + // is not tracked, becoming enabled + if (!hasTarget && linkRelSpecified) { + const payloadEntry = await this.checkLink(target); + if (payloadEntry) { + this.monetizationLinks.set(target, payloadEntry.details); + startMonetizationPayload.push(payloadEntry.details); + } + handledTags.add(target); + } else if (hasTarget && !linkRelSpecified) { + const payloadEntry = this.onRemovedLink(target); + stopMonetizationPayload.push(payloadEntry); + handledTags.add(target); + } else if (!hasTarget && !linkRelSpecified) { + // ignore these changes + handledTags.add(target); + } else if (hasTarget && linkRelSpecified) { + if ( + record.type === 'attributes' && + record.attributeName === 'disabled' && + target instanceof HTMLLinkElement && + target.getAttribute('disabled') !== record.oldValue + ) { + const wasDisabled = record.oldValue !== null; + const isDisabled = target.hasAttribute('disabled'); + if (wasDisabled != isDisabled) { + try { + const details = this.monetizationLinks.get(target); + if (!details) { + throw new Error('Could not find details for monetization node'); + } + if (isDisabled) { + stopMonetizationPayload.push({ + requestId: details.requestId, + intent: 'disable', + }); + } else { + startMonetizationPayload.push(details); + } + } catch { + const payloadEntry = await this.checkLink(target); + if (payloadEntry) { + this.monetizationLinks.set(target, payloadEntry.details); + startMonetizationPayload.push(payloadEntry.details); + } + } + + handledTags.add(target); + } + } else if ( + record.type === 'attributes' && + record.attributeName === 'href' && + target instanceof HTMLLinkElement && + target.href !== record.oldValue + ) { + stopMonetizationPayload.push(this.onRemovedLink(target)); + const payloadEntry = await this.checkLink(target); + if (payloadEntry) { + startMonetizationPayload.push(payloadEntry.details); + } + handledTags.add(target); + } + } + } + + await this.sendStopMonetization(stopMonetizationPayload); + void this.sendStartMonetization(startMonetizationPayload); + } + + private async onAddedNode(node: Node) { + if (node instanceof HTMLElement) { + this.dispatchOnMonetizationAttrChangedEvent(node); + } + + if (node instanceof HTMLLinkElement) { + return await this.onAddedLink(node); + } + return null; + } + + private async onAddedLink(link: HTMLLinkElement) { + this.observeLinkAttrs(link); + const res = await this.checkLink(link); + if (res) { + this.monetizationLinks.set(link, res.details); + } + return res; + } + + private onRemovedLink(link: HTMLLinkElement): StopMonetizationPayloadEntry { + const details = this.monetizationLinks.get(link); + if (!details) { + throw new Error( + 'Could not find details for monetization node ' + + // node is removed, so the reference can not be displayed + link.outerHTML.slice(0, 200), + ); + } + + this.monetizationLinks.delete(link); + + return { requestId: details.requestId, intent: 'remove' }; + } +} diff --git a/src/content/services/monetizationTagManager.ts b/src/content/services/monetizationTagManager.ts deleted file mode 100644 index af50d29d..00000000 --- a/src/content/services/monetizationTagManager.ts +++ /dev/null @@ -1,566 +0,0 @@ -import { EventEmitter } from 'events'; -import { mozClone } from '../utils'; -import type { MonetizationTagDetails } from '../types'; -import type { WalletAddress } from '@interledger/open-payments/dist/types'; -import { checkWalletAddressUrlFormat } from '../utils'; -import type { - EmitToggleWMPayload, - MonetizationEventPayload, - ResumeMonetizationPayload, - StartMonetizationPayload, - StopMonetizationPayload, -} from '@/shared/messages'; -import { ContentToContentAction } from '../messages'; -import type { Cradle } from '@/content/container'; - -export type MonetizationTag = HTMLLinkElement; - -interface FireOnMonetizationChangeIfHaveAttributeParams { - node: HTMLElement; - changeDetected?: boolean; -} - -export class MonetizationTagManager extends EventEmitter { - private window: Cradle['window']; - private document: Cradle['document']; - private logger: Cradle['logger']; - private message: Cradle['message']; - - private isTopFrame: boolean; - private isFirstLevelFrame: boolean; - private documentObserver: MutationObserver; - private monetizationTagAttrObserver: MutationObserver; - private id: string; - private monetizationTags = new Map(); - - constructor({ window, document, logger, message }: Cradle) { - super(); - Object.assign(this, { - window, - document, - logger, - message, - }); - - this.documentObserver = new MutationObserver((records) => - this.onWholeDocumentObserved(records), - ); - this.monetizationTagAttrObserver = new MutationObserver((records) => - this.onMonetizationTagAttrsChange(records), - ); - - document.addEventListener('visibilitychange', async () => { - if (document.visibilityState === 'visible') { - await this.resumeAllMonetization(); - } else { - this.stopAllMonetization(); - } - }); - - this.isTopFrame = window === window.top; - this.isFirstLevelFrame = window.parent === window.top; - this.id = crypto.randomUUID(); - - if (!this.isTopFrame && this.isFirstLevelFrame) { - this.bindMessageHandler(); - } - } - - private dispatchLoadEvent(tag: MonetizationTag) { - tag.dispatchEvent(new Event('load')); - } - - private dispatchErrorEvent(tag: MonetizationTag) { - tag.dispatchEvent(new Event('error')); - } - - dispatchMonetizationEvent({ requestId, details }: MonetizationEventPayload) { - this.monetizationTags.forEach((tagDetails, tag) => { - if (tagDetails.requestId !== requestId) return; - - tag.dispatchEvent( - new CustomEvent('__wm_ext_monetization', { - detail: mozClone(details, this.document), - bubbles: true, - }), - ); - }); - return; - } - - private async resumeAllMonetization() { - const response = await this.message.send('IS_WM_ENABLED'); - - if (response.success && response.payload) { - const resumeMonetizationTags: ResumeMonetizationPayload[] = []; - - this.monetizationTags.forEach((value) => { - if (value.requestId && value.walletAddress) { - resumeMonetizationTags.push({ requestId: value.requestId }); - } - }); - - this.sendResumeMonetization(resumeMonetizationTags); - } - } - - private stopAllMonetization(intent?: StopMonetizationPayload['intent']) { - const stopMonetizationTags: StopMonetizationPayload[] = []; - this.monetizationTags.forEach((value) => { - if (value.requestId && value.walletAddress) { - stopMonetizationTags.push({ requestId: value.requestId, intent }); - } - }); - - this.sendStopMonetization(stopMonetizationTags); - } - - private async onWholeDocumentObserved(records: MutationRecord[]) { - const startMonetizationTagsPromises: Promise[] = - []; - const stopMonetizationTags: StopMonetizationPayload[] = []; - - for (const record of records) { - if (record.type === 'childList') { - record.removedNodes.forEach(async (node) => { - const stopMonetizationTag = this.checkRemoved(node); - if (stopMonetizationTag) - stopMonetizationTags.push(stopMonetizationTag); - }); - } - } - - await this.sendStopMonetization(stopMonetizationTags); - - if (this.isTopFrame) { - for (const record of records) { - if (record.type === 'childList') { - record.addedNodes.forEach(async (node) => { - const startMonetizationTag = this.checkAdded(node); - startMonetizationTagsPromises.push(startMonetizationTag); - }); - } - } - - Promise.allSettled(startMonetizationTagsPromises).then((result) => { - const startMonetizationTags: StartMonetizationPayload[] = []; - result.forEach((res) => { - if (res.status === 'fulfilled' && res.value) { - startMonetizationTags.push(res.value); - } - }); - - this.sendStartMonetization(startMonetizationTags); - }); - } - - this.onOnMonetizationChangeObserved(records); - } - - async onMonetizationTagAttrsChange(records: MutationRecord[]) { - const handledTags = new Set(); - const startMonetizationTags: StartMonetizationPayload[] = []; - const stopMonetizationTags: StopMonetizationPayload[] = []; - - // Check for a non specified link with the type now specified and - // just treat it as a newly seen, monetization tag - for (const record of records) { - const target = record.target as MonetizationTag; - if (handledTags.has(target)) { - continue; - } - const hasTarget = this.monetizationTags.has(target); - const typeSpecified = - target instanceof HTMLLinkElement && target.rel === 'monetization'; - // this will also handle the case of a @disabled tag that - // is not tracked, becoming enabled - if (!hasTarget && typeSpecified) { - const startMonetizationTag = await this.onAddedTag(target); - if (startMonetizationTag) - startMonetizationTags.push(startMonetizationTag); - - handledTags.add(target); - } else if (hasTarget && !typeSpecified) { - const stopMonetizationTag = this.onRemovedTag(target); - stopMonetizationTags.push(stopMonetizationTag); - - handledTags.add(target); - } else if (!hasTarget && !typeSpecified) { - // ignore these changes - handledTags.add(target); - } else if (hasTarget && typeSpecified) { - if ( - record.type === 'attributes' && - record.attributeName === 'disabled' && - target instanceof HTMLLinkElement && - target.getAttribute('disabled') !== record.oldValue - ) { - const wasDisabled = record.oldValue !== null; - const isDisabled = target.hasAttribute('disabled'); - if (wasDisabled != isDisabled) { - try { - const { requestId, walletAddress } = this.getTagDetails( - target, - 'onChangeDisabled', - ); - if (isDisabled) { - stopMonetizationTags.push({ requestId, intent: 'disable' }); - } else if (walletAddress) { - startMonetizationTags.push({ requestId, walletAddress }); - } - } catch { - const startMonetizationPayload = await this.onAddedTag(target); - if (startMonetizationPayload) { - startMonetizationTags.push(startMonetizationPayload); - } - } - - handledTags.add(target); - } - } else if ( - record.type === 'attributes' && - record.attributeName === 'href' && - target instanceof HTMLLinkElement && - target.href !== record.oldValue - ) { - const { startMonetizationTag, stopMonetizationTag } = - await this.onChangedWalletAddressUrl(target); - if (startMonetizationTag) - startMonetizationTags.push(startMonetizationTag); - if (stopMonetizationTag) - stopMonetizationTags.push(stopMonetizationTag); - - handledTags.add(target); - } - } - } - - await this.sendStopMonetization(stopMonetizationTags); - this.sendStartMonetization(startMonetizationTags); - } - - private async checkAdded(node: Node) { - if (node instanceof HTMLElement) { - this.fireOnMonetizationAttrChangedEvent({ node }); - } - - if (node instanceof HTMLLinkElement) { - this.observeMonetizationTagAttrs(node); - return await this.onAddedTag(node); - } - - return null; - } - - private checkRemoved(node: Node) { - return node instanceof HTMLLinkElement && this.monetizationTags.has(node) - ? this.onRemovedTag(node) - : null; - } - - private observeMonetizationTagAttrs(tag: MonetizationTag) { - this.monetizationTagAttrObserver.observe(tag, { - childList: false, - attributeOldValue: true, - attributeFilter: ['href', 'disabled', 'rel', 'crossorigin', 'type'], - }); - } - - private getTagDetails(tag: MonetizationTag, caller = '') { - const tagDetails = this.monetizationTags.get(tag); - - if (!tagDetails) { - throw new Error( - `${caller}: tag not tracked: ${tag.outerHTML.slice(0, 200)}`, - ); - } - - return tagDetails; - } - - // If wallet address changed, remove old tag and add new one - async onChangedWalletAddressUrl( - tag: MonetizationTag, - wasDisabled = false, - isDisabled = false, - ) { - let stopMonetizationTag = null; - - if (!wasDisabled && !isDisabled) { - stopMonetizationTag = this.onRemovedTag(tag); - } - - const startMonetizationTag = await this.onAddedTag(tag); - - return { startMonetizationTag, stopMonetizationTag }; - } - - private onOnMonetizationChangeObserved(records: MutationRecord[]) { - for (const record of records) { - if ( - record.type === 'attributes' && - record.target instanceof HTMLElement && - record.attributeName === 'onmonetization' - ) { - this.fireOnMonetizationAttrChangedEvent({ - node: record.target, - changeDetected: true, - }); - } - } - } - - private fireOnMonetizationAttrChangedEvent({ - node, - changeDetected = false, - }: FireOnMonetizationChangeIfHaveAttributeParams) { - const attribute = node.getAttribute('onmonetization'); - - if (!attribute && !changeDetected) return; - - const customEvent = new CustomEvent('__wm_ext_onmonetization_attr_change', { - bubbles: true, - detail: mozClone({ attribute }, this.document), - }); - - node.dispatchEvent(customEvent); - } - - private isDocumentReady() { - return ( - (document.readyState === 'interactive' || - document.readyState === 'complete') && - document.visibilityState === 'visible' - ); - } - - start(): void { - if (this.isDocumentReady()) { - this.run(); - return; - } - - document.addEventListener( - 'readystatechange', - () => { - if (this.isDocumentReady()) { - this.run(); - } else { - document.addEventListener( - 'visibilitychange', - () => { - if (this.isDocumentReady()) { - this.run(); - } - }, - { once: true }, - ); - } - }, - { once: true }, - ); - } - - private run() { - if (!this.isTopFrame && this.isFirstLevelFrame) { - this.window.parent.postMessage( - { - message: ContentToContentAction.INITIALIZE_IFRAME, - id: this.id, - }, - '*', - ); - } - - let monetizationTags: NodeListOf | MonetizationTag[]; - - if (this.isTopFrame) { - monetizationTags = this.document.querySelectorAll( - 'link[rel="monetization"]', - ); - } else { - const monetizationTag: MonetizationTag | null = - this.document.querySelector('head link[rel="monetization"]'); - monetizationTags = monetizationTag ? [monetizationTag] : []; - } - - const startMonetizationTagsPromises: Promise[] = - []; - - monetizationTags.forEach(async (tag) => { - try { - this.observeMonetizationTagAttrs(tag); - const startMonetizationTag = this.onAddedTag(tag); - startMonetizationTagsPromises.push(startMonetizationTag); - } catch (e) { - this.logger.error(e); - } - }); - - Promise.allSettled(startMonetizationTagsPromises).then((result) => { - const startMonetizationTags: StartMonetizationPayload[] = []; - result.forEach((res) => { - if (res.status === 'fulfilled' && res.value) { - startMonetizationTags.push(res.value); - } - }); - - this.sendStartMonetization(startMonetizationTags); - }); - - const onMonetizations: NodeListOf = - this.document.querySelectorAll('[onmonetization]'); - - onMonetizations.forEach((node) => { - this.fireOnMonetizationAttrChangedEvent({ node }); - }); - - this.documentObserver.observe(this.document, { - subtree: true, - childList: true, - attributeFilter: ['onmonetization'], - }); - } - - stop() { - this.documentObserver.disconnect(); - this.monetizationTagAttrObserver.disconnect(); - this.monetizationTags.clear(); - } - - // Remove tag from list & stop monetization - private onRemovedTag(tag: MonetizationTag): StopMonetizationPayload { - const { requestId } = this.getTagDetails(tag, 'onRemovedTag'); - this.monetizationTags.delete(tag); - - return { requestId, intent: 'remove' }; - } - - // Add tag to list & start monetization - private async onAddedTag( - tag: MonetizationTag, - crtRequestId?: string, - ): Promise { - const walletAddress = await this.checkTag(tag); - if (!walletAddress) return null; - - const requestId = crtRequestId ?? crypto.randomUUID(); - const details: MonetizationTagDetails = { - walletAddress, - requestId, - }; - - this.monetizationTags.set(tag, details); - return { walletAddress, requestId }; - } - - private sendStartMonetization(tags: StartMonetizationPayload[]) { - if (!tags.length) return; - - if (this.isTopFrame) { - if (tags.length) { - void this.message.send('START_MONETIZATION', tags); - } - } else if (this.isFirstLevelFrame) { - this.window.parent.postMessage( - { - message: ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_START, - id: this.id, - payload: tags, - }, - '*', - ); - } - } - - private async sendStopMonetization(tags: StopMonetizationPayload[]) { - if (!tags.length) return; - await this.message.send('STOP_MONETIZATION', tags); - } - - private sendResumeMonetization(tags: ResumeMonetizationPayload[]) { - if (this.isTopFrame) { - if (tags.length) { - void this.message.send('RESUME_MONETIZATION', tags); - } - } else if (this.isFirstLevelFrame) { - this.window.parent.postMessage( - { - message: ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_RESUME, - id: this.id, - payload: tags, - }, - '*', - ); - } - } - - // Check tag to be enabled and for valid wallet address - private async checkTag(tag: MonetizationTag): Promise { - if (!(tag instanceof HTMLLinkElement && tag.rel === 'monetization')) - return null; - - if (tag.hasAttribute('disabled')) return null; - - const walletAddressInfo = await this.validateWalletAddress(tag); - - return walletAddressInfo; - } - - private async validateWalletAddress( - tag: MonetizationTag, - ): Promise { - const walletAddressUrl = tag.href.trim(); - try { - checkWalletAddressUrlFormat(walletAddressUrl); - const response = await this.message.send('CHECK_WALLET_ADDRESS_URL', { - walletAddressUrl, - }); - - if (response.success === false) { - throw new Error( - `Could not retrieve wallet address information for ${JSON.stringify(walletAddressUrl)}.`, - ); - } - - this.dispatchLoadEvent(tag); - return response.payload; - } catch (e) { - this.logger.error(e); - this.dispatchErrorEvent(tag); - return null; - } - } - - private bindMessageHandler() { - this.window.addEventListener('message', (event) => { - const { message, id, payload } = event.data; - - if (event.origin === window.location.href || id !== this.id) return; - - switch (message) { - case ContentToContentAction.START_MONETIZATION: - if (payload.length) { - void this.message.send('START_MONETIZATION', payload); - } - return; - case ContentToContentAction.RESUME_MONETIZATION: - if (payload.length) { - void this.message.send('RESUME_MONETIZATION', payload); - } - return; - default: - return; - } - }); - } - - async toggleWM({ enabled }: EmitToggleWMPayload) { - if (enabled) { - await this.resumeAllMonetization(); - } else { - // TODO: https://github.com/interledger/web-monetization-extension/issues/452 - this.stopAllMonetization(); - } - } -} diff --git a/src/content/types.ts b/src/content/types.ts deleted file mode 100644 index 9daa52fe..00000000 --- a/src/content/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { WalletAddress } from '@interledger/open-payments/dist/types'; - -export type MonetizationTag = HTMLLinkElement & { href?: string }; -export type MonetizationTagList = NodeListOf; - -export type MonetizationTagDetails = { - walletAddress: WalletAddress | null; - requestId: string; -}; diff --git a/src/content/utils.ts b/src/content/utils.ts index 1a341747..2899b74d 100644 --- a/src/content/utils.ts +++ b/src/content/utils.ts @@ -1,34 +1,5 @@ export class WalletAddressFormatError extends Error {} -export function checkWalletAddressUrlFormat(walletAddressUrl: string): void { - let url: URL; - try { - url = new URL(walletAddressUrl); - if (url.protocol !== 'https:') { - throw new WalletAddressFormatError( - `Wallet address URL must be specified as a fully resolved https:// url, ` + - `got ${JSON.stringify(walletAddressUrl)} `, - ); - } - } catch (e) { - if (e instanceof WalletAddressFormatError) { - throw e; - } else { - throw new WalletAddressFormatError( - `Invalid wallet address URL: ${JSON.stringify(walletAddressUrl)}`, - ); - } - } - - const { hash, search, port, username, password } = url; - - if (hash || search || port || username || password) { - throw new WalletAddressFormatError( - `Wallet address URL must not contain query/fragment/port/username/password elements. Received: ${JSON.stringify({ hash, search, port, username, password })}`, - ); - } -} - type DefaultView = WindowProxy & typeof globalThis; type CloneInto = (obj: unknown, _window: DefaultView | null) => typeof obj; declare const cloneInto: CloneInto | undefined; diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index 696107d6..83128f5a 100644 --- a/src/shared/helpers.ts +++ b/src/shared/helpers.ts @@ -223,6 +223,10 @@ export function objectEquals>(a: T, b: T) { return JSON.stringify(a, keysA.sort()) === JSON.stringify(b, keysB.sort()); } +export function isNotNull(value: T | null): value is T { + return value !== null; +} + export const removeQueryParams = (urlString: string) => { const url = new URL(urlString); return url.origin + url.pathname; diff --git a/src/shared/messages.ts b/src/shared/messages.ts index f9dea560..960d3a33 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -143,43 +143,46 @@ export type PopupToBackgroundMessage = { // #endregion // #region Content ↦ BG -export interface CheckWalletAddressUrlPayload { +export interface GetWalletAddressInfoPayload { walletAddressUrl: string; } -export interface StartMonetizationPayload { +export interface StartMonetizationPayloadEntry { walletAddress: WalletAddress; requestId: string; } +export type StartMonetizationPayload = StartMonetizationPayloadEntry[]; -export interface StopMonetizationPayload { +export interface StopMonetizationPayloadEntry { requestId: string; intent?: 'remove' | 'disable'; } +export type StopMonetizationPayload = StopMonetizationPayloadEntry[]; -export interface ResumeMonetizationPayload { +export interface ResumeMonetizationPayloadEntry { requestId: string; } +export type ResumeMonetizationPayload = ResumeMonetizationPayloadEntry[]; export interface IsTabMonetizedPayload { value: boolean; } export type ContentToBackgroundMessage = { - CHECK_WALLET_ADDRESS_URL: { - input: CheckWalletAddressUrlPayload; + GET_WALLET_ADDRESS_INFO: { + input: GetWalletAddressInfoPayload; output: WalletAddress; }; STOP_MONETIZATION: { - input: StopMonetizationPayload[]; + input: StopMonetizationPayload; output: never; }; START_MONETIZATION: { - input: StartMonetizationPayload[]; + input: StartMonetizationPayload; output: never; }; RESUME_MONETIZATION: { - input: ResumeMonetizationPayload[]; + input: ResumeMonetizationPayload; output: never; }; IS_WM_ENABLED: { @@ -213,19 +216,11 @@ export interface MonetizationEventPayload { details: MonetizationEventDetails; } -export interface EmitToggleWMPayload { - enabled: boolean; -} - export type BackgroundToContentMessage = { MONETIZATION_EVENT: { input: MonetizationEventPayload; output: never; }; - EMIT_TOGGLE_WM: { - input: EmitToggleWMPayload; - output: never; - }; }; export type ToContentMessage = {