diff --git a/src/content/container.ts b/src/content/container.ts index 9e6a11bc..45ac379e 100644 --- a/src/content/container.ts +++ b/src/content/container.ts @@ -4,12 +4,15 @@ import { createLogger, Logger } from '@/shared/logger' import { ContentScript } from './services/contentScript' import { MonetizationTagManager } from './services/monetizationTagManager' import { LOG_LEVEL } from '@/shared/defines' +import { FrameManager } from './services/frameManager' interface Cradle { logger: Logger browser: Browser document: Document + window: Window monetizationTagManager: MonetizationTagManager + frameManager: FrameManager contentScript: ContentScript } @@ -24,6 +27,12 @@ export const configureContainer = () => { logger: asValue(logger), browser: asValue(browser), document: asValue(document), + window: asValue(window), + frameManager: asClass(FrameManager) + .singleton() + .inject(() => ({ + logger: logger.getLogger('content-script:frameManager') + })), monetizationTagManager: asClass(MonetizationTagManager) .singleton() .inject(() => ({ diff --git a/src/content/messages.ts b/src/content/messages.ts new file mode 100644 index 00000000..69d49f67 --- /dev/null +++ b/src/content/messages.ts @@ -0,0 +1,10 @@ +export enum ContentToContentAction { + INITILIZE_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', + IS_FRAME_MONETIZED = 'IS_FRAME_MONETIZED' +} diff --git a/src/content/services/contentScript.ts b/src/content/services/contentScript.ts index 4f7b22bf..e1549613 100644 --- a/src/content/services/contentScript.ts +++ b/src/content/services/contentScript.ts @@ -3,20 +3,33 @@ import { MonetizationTagManager } from './monetizationTagManager' import { type Browser } from 'webextension-polyfill' import { BackgroundToContentAction, ToContentMessage } from '@/shared/messages' import { failure } from '@/shared/helpers' +import { FrameManager } from './frameManager' export class ContentScript { + private isFirstLevelFrame: boolean + private isTopFrame: boolean + constructor( private browser: Browser, + private window: Window, private logger: Logger, - private monetizationTagManager: MonetizationTagManager + private monetizationTagManager: MonetizationTagManager, + private frameManager: FrameManager ) { + this.isTopFrame = window === window.top + this.isFirstLevelFrame = window.parent === window.top + this.bindMessageHandler() } start() { - this.logger.info('Content script started') + if (this.isFirstLevelFrame) { + this.logger.info('Content script started') + + if (this.isTopFrame) this.frameManager.start() - this.monetizationTagManager.start() + this.monetizationTagManager.start() + } } bindMessageHandler() { diff --git a/src/content/services/frameManager.ts b/src/content/services/frameManager.ts new file mode 100644 index 00000000..88b84652 --- /dev/null +++ b/src/content/services/frameManager.ts @@ -0,0 +1,289 @@ +import { Logger } from '@/shared/logger' +import { isTabMonetized, stopMonetization } from '../lib/messages' +import { ContentToContentAction } from '../messages' + +export class FrameManager { + private documentObserver: MutationObserver + private frameAllowAttrObserver: MutationObserver + private isFrameMonetized: boolean + private frames = new Map< + HTMLIFrameElement, + { frameId: string | null; requestIds: string[]; isFrameMonetized?: boolean } + >() + + constructor( + private window: Window, + private document: Document, + private logger: Logger + ) { + this.documentObserver = new MutationObserver((records) => + this.onWholeDocumentObserved(records) + ) + + this.frameAllowAttrObserver = new MutationObserver((records) => + this.onFrameAllowAttrChange(records) + ) + } + + private findIframe(sourceWindow: Window): HTMLIFrameElement | null { + const iframes = this.frames.keys() + let frame + + do { + frame = iframes.next() + if (frame.done) return null + if (frame.value.contentWindow === sourceWindow) return frame.value + } while (!frame.done) + + return null + } + + private observeDocumentForFrames() { + this.documentObserver.observe(this.document, { + subtree: true, + childList: true + }) + } + + private observeFrameAllowAttrs(frame: HTMLIFrameElement) { + this.frameAllowAttrObserver.observe(frame, { + childList: false, + attributeOldValue: true, + attributeFilter: ['allow'] + }) + } + + async onFrameAllowAttrChange(records: MutationRecord[]) { + const handledTags = new Set() + + // 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 HTMLIFrameElement + if (handledTags.has(target)) { + continue + } + const hasTarget = this.frames.has(target) + const typeSpecified = + target instanceof HTMLIFrameElement && target.allow === 'monetization' + + if (!hasTarget && typeSpecified) { + await this.onAddedFrame(target) + handledTags.add(target) + } else if (hasTarget && !typeSpecified) { + this.onRemovedFrame(target) + handledTags.add(target) + } else if (!hasTarget && !typeSpecified) { + // ignore these changes + handledTags.add(target) + } + } + } + + private async onAddedFrame(frame: HTMLIFrameElement) { + this.frames.set(frame, { + frameId: null, + requestIds: [], + isFrameMonetized: false + }) + } + + private async onRemovedFrame(frame: HTMLIFrameElement) { + this.logger.info('onRemovedFrame', frame) + + const frameDetails = this.frames.get(frame) + + frameDetails?.requestIds.forEach((requestId) => + stopMonetization({ requestId }) + ) + + this.frames.delete(frame) + + let isMonetized = false + + this.frames.forEach((value) => { + if (value.isFrameMonetized) isMonetized = true + }) + + isTabMonetized({ value: isMonetized || this.isFrameMonetized }) + } + + private onWholeDocumentObserved(records: MutationRecord[]) { + for (const record of records) { + if (record.type === 'childList') { + record.removedNodes.forEach((node) => this.check('removed', node)) + } + } + + for (const record of records) { + if (record.type === 'childList') { + record.addedNodes.forEach((node) => this.check('added', node)) + } + } + } + + async check(op: string, node: Node) { + if (node instanceof HTMLIFrameElement) { + if (op === 'added') { + this.observeFrameAllowAttrs(node) + await this.onAddedFrame(node) + } else if (op === 'removed' && this.frames.has(node)) { + this.onRemovedFrame(node) + } + } + } + + start(): void { + this.bindMessageHandler() + + if ( + document.readyState === 'interactive' || + document.readyState === 'complete' + ) + this.run() + + document.addEventListener( + 'readystatechange', + () => { + if (document.readyState === 'interactive') { + this.run() + } + }, + { once: true } + ) + } + + private run() { + const frames: NodeListOf = + this.document.querySelectorAll('iframe') + + frames.forEach(async (frame) => { + try { + this.observeFrameAllowAttrs(frame) + await this.onAddedFrame(frame) + } catch (e) { + this.logger.error(e) + } + }) + + this.observeDocumentForFrames() + } + + private bindMessageHandler() { + this.window.addEventListener( + 'message', + (event: any) => { + const { message, payload, id } = event.data + const frame = this.findIframe(event.source) + + if (!frame) { + if (message === ContentToContentAction.IS_FRAME_MONETIZED) { + event.stopPropagation() + + let isMonetized = false + + this.isFrameMonetized = payload.isMonetized + this.frames.forEach((value) => { + if (value.isFrameMonetized) isMonetized = true + }) + + isTabMonetized({ value: isMonetized || this.isFrameMonetized }) + } + return + } + + if (event.origin === this.window.location.href) return + + const frameDetails = this.frames.get(frame) + + switch (message) { + case ContentToContentAction.INITILIZE_IFRAME: + event.stopPropagation() + this.frames.set(frame, { + frameId: id, + requestIds: [], + isFrameMonetized: false + }) + return + + case ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_START: + event.stopPropagation() + if (frame.allow === 'monetization') { + this.frames.set(frame, { + frameId: id, + requestIds: [payload.requestId], + isFrameMonetized: true + }) + + event.source.postMessage( + { + message: ContentToContentAction.START_MONETIZATION, + id, + payload + }, + '*' + ) + } + + return + + case ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_RESUME: + event.stopPropagation() + if (frame.allow === 'monetization') { + this.frames.set(frame, { + frameId: id, + requestIds: [payload.requestId], + isFrameMonetized: true + }) + + event.source.postMessage( + { + message: ContentToContentAction.RESUME_MONETIZATION, + id, + payload + }, + '*' + ) + } + return + + case ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_STOP: + event.stopPropagation() + if (frameDetails?.requestIds.length) { + event.source.postMessage( + { + message: ContentToContentAction.STOP_MONETIZATION, + id, + payload + }, + '*' + ) + } + + return + + case ContentToContentAction.IS_FRAME_MONETIZED: { + event.stopPropagation() + let isMonetized = false + if (!frameDetails) return + + this.frames.set(frame, { + ...frameDetails, + isFrameMonetized: payload.isMonetized + }) + this.frames.forEach((value) => { + if (value.isFrameMonetized) isMonetized = true + }) + + isTabMonetized({ value: isMonetized || this.isFrameMonetized }) + + return + } + default: + return + } + }, + { capture: true } + ) + } +} diff --git a/src/content/services/monetizationTagManager.ts b/src/content/services/monetizationTagManager.ts index ec647c67..0e3cf8a5 100644 --- a/src/content/services/monetizationTagManager.ts +++ b/src/content/services/monetizationTagManager.ts @@ -7,7 +7,6 @@ import { WalletAddress } from '@interledger/open-payments/dist/types' import { checkWalletAddressUrlFormat } from '../utils' import { checkWalletAddressUrlCall, - isTabMonetized, isWMEnabled, resumeMonetization, startMonetization, @@ -17,6 +16,7 @@ import { EmitToggleWMPayload, MonetizationEventPayload } from '@/shared/messages' +import { ContentToContentAction } from '../messages' export type MonetizationTag = HTMLLinkElement @@ -26,13 +26,17 @@ interface FireOnMonetizationChangeIfHaveAttributeParams { } export class MonetizationTagManager extends EventEmitter { + private isTopFrame: boolean + private isFirstLevelFrame: boolean private documentObserver: MutationObserver private monetizationTagAttrObserver: MutationObserver + private id: string private iconUpdated: boolean private monetizationTags = new Map() constructor( + private window: Window, private document: Document, private logger: Logger ) { @@ -49,6 +53,14 @@ export class MonetizationTagManager extends EventEmitter { ? await this.resumeAllMonetization() : await this.stopAllMonetization() }) + + this.isTopFrame = window === window.top + this.isFirstLevelFrame = window.parent === window.top + this.id = crypto.randomUUID() + + if (!this.isTopFrame && this.isFirstLevelFrame) { + this.bindMessageHandler() + } } dispatchMonetizationEvent({ requestId, details }: MonetizationEventPayload) { @@ -70,43 +82,81 @@ export class MonetizationTagManager extends EventEmitter { if (response.success && response.payload) { let validTagsCount = 0 - this.monetizationTags.forEach((value) => { if (value.requestId && value.walletAddress) { - resumeMonetization({ requestId: value.requestId }) + if (this.isTopFrame) { + resumeMonetization({ requestId: value.requestId }) + } else if (this.isFirstLevelFrame) { + this.window.parent.postMessage( + { + message: + ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_RESUME, + id: this.id, + payload: { requestId: value.requestId } + }, + '*' + ) + } ++validTagsCount } }) - isTabMonetized({ value: validTagsCount > 0 }) + if (this.isTopFrame) { + this.window.postMessage( + { + message: ContentToContentAction.IS_FRAME_MONETIZED, + id: this.id, + payload: { isMonetized: validTagsCount > 0 } + }, + '*' + ) + } else if (this.isFirstLevelFrame) { + this.window.parent.postMessage( + { + message: ContentToContentAction.IS_FRAME_MONETIZED, + id: this.id, + payload: { isMonetized: validTagsCount > 0 } + }, + '*' + ) + } } } private stopAllMonetization() { this.monetizationTags.forEach((value) => { - if (value.requestId && value.walletAddress) - stopMonetization({ requestId: value.requestId }) + if (value.requestId && value.walletAddress) { + if (this.isTopFrame) { + stopMonetization({ requestId: value.requestId }) + } else if (this.isFirstLevelFrame) { + this.window.parent.postMessage( + { + message: ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_STOP, + id: this.id, + payload: { requestId: value.requestId } + }, + '*' + ) + } + } }) } private onWholeDocumentObserved(records: MutationRecord[]) { this.iconUpdated = false - this.logger.info('document mutation records.length=', records.length) - for (const record of records) { - this.logger.info('Record', record.type, record.target) if (record.type === 'childList') { record.removedNodes.forEach((node) => this.check('removed', node)) } } - for (const record of records) { - this.logger.info('Record', record.type, record.target) - if (record.type === 'childList') { - record.addedNodes.forEach((node) => this.check('added', node)) + if (this.isTopFrame) + for (const record of records) { + if (record.type === 'childList') { + record.addedNodes.forEach((node) => this.check('added', node)) + } } - } this.onOnMonetizationChangeObserved(records) } @@ -164,8 +214,6 @@ export class MonetizationTagManager extends EventEmitter { } async check(op: string, node: Node) { - this.logger.info('head node', op, node) - if (node instanceof HTMLLinkElement) { if (op === 'added') { this.observeMonetizationTagAttrs(node) @@ -238,9 +286,7 @@ export class MonetizationTagManager extends EventEmitter { detail: mozClone({ attribute }, this.document) }) - const result = node.dispatchEvent(customEvent) - - this.logger.info('dispatched onmonetization-attr-changed ev', result) + node.dispatchEvent(customEvent) } start(): void { @@ -262,10 +308,27 @@ export class MonetizationTagManager extends EventEmitter { } private run() { - this.iconUpdated = false + if (!this.isTopFrame && this.isFirstLevelFrame) { + this.window.parent.postMessage( + { + message: ContentToContentAction.INITILIZE_IFRAME, + id: this.id + }, + '*' + ) + } + + let monetizationTags: NodeListOf | MonetizationTag[] + + if (this.isTopFrame) { + monetizationTags = this.document.querySelectorAll('link') + } else { + const monetizationTag: MonetizationTag | null = + this.document.querySelector('head link[rel="monetization"]') + monetizationTags = monetizationTag ? [monetizationTag] : [] + } - const monetizationTags: NodeListOf = - this.document.querySelectorAll('link') + this.iconUpdated = false monetizationTags.forEach(async (tag) => { try { @@ -315,7 +378,25 @@ export class MonetizationTagManager extends EventEmitter { if (value.requestId && value.walletAddress) ++validTagsCount }) - isTabMonetized({ value: validTagsCount > 0 }) + if (this.isTopFrame) { + this.window.postMessage( + { + message: ContentToContentAction.IS_FRAME_MONETIZED, + id: this.id, + payload: { isMonetized: validTagsCount > 0 } + }, + '*' + ) + } else if (this.isFirstLevelFrame) { + this.window.parent.postMessage( + { + message: ContentToContentAction.IS_FRAME_MONETIZED, + id: this.id, + payload: { isMonetized: validTagsCount > 0 } + }, + '*' + ) + } } // Add tag to list & start monetization @@ -331,11 +412,42 @@ export class MonetizationTagManager extends EventEmitter { this.monetizationTags.set(tag, details) if (walletAddress) { - startMonetization({ requestId, walletAddress }) - - if (!this.iconUpdated) { - isTabMonetized({ value: true }) - this.iconUpdated = true + if (this.isTopFrame) { + startMonetization({ requestId, walletAddress }) + if (!this.iconUpdated) { + this.window.postMessage( + { + message: ContentToContentAction.IS_FRAME_MONETIZED, + id: this.id, + payload: { isMonetized: true } + }, + '*' + ) + this.iconUpdated = true + } + } else if (this.isFirstLevelFrame) { + this.window.parent.postMessage( + { + message: ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_START, + id: this.id, + payload: { + walletAddress, + requestId + } + }, + '*' + ) + if (!this.iconUpdated) { + this.window.parent.postMessage( + { + message: ContentToContentAction.IS_FRAME_MONETIZED, + id: this.id, + payload: { isMonetized: true } + }, + '*' + ) + this.iconUpdated = true + } } } } @@ -392,6 +504,28 @@ export class MonetizationTagManager extends EventEmitter { } } + 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: + startMonetization(payload) + return + case ContentToContentAction.RESUME_MONETIZATION: + resumeMonetization(payload) + return + case ContentToContentAction.STOP_MONETIZATION: + stopMonetization(payload) + return + default: + return + } + }) + } + async toggleWM({ enabled }: EmitToggleWMPayload) { if (enabled) { await this.resumeAllMonetization() diff --git a/src/manifest.json b/src/manifest.json index 5857ea9a..7b446785 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -11,12 +11,14 @@ "content_scripts": [ { "matches": ["http://*/*", "https://*/*", ""], - "js": ["content/content.js"] + "js": ["content/content.js"], + "all_frames": true }, { "run_at": "document_start", "matches": ["http://*/*", "https://*/*"], - "js": ["contentStatic/contentStatic.js"] + "js": ["contentStatic/contentStatic.js"], + "all_frames": true } ], "background": { diff --git a/src/popup/pages/Home.tsx b/src/popup/pages/Home.tsx index aeedca34..624cb33b 100644 --- a/src/popup/pages/Home.tsx +++ b/src/popup/pages/Home.tsx @@ -5,8 +5,8 @@ import { Slider } from '../components/ui/Slider' import { toggleWM, updateRateOfPay as updateRateOfPay_ } from '../lib/messages' import { Label } from '../components/ui/Label' import { getCurrencySymbol, roundWithPrecision } from '../lib/utils' -import { debounceAsync } from '@/shared/helpers' import { PayWebsiteForm } from '../components/PayWebsiteForm' +import { debounceAsync } from '@/shared/helpers' import { Switch } from '../components/ui/Switch' const updateRateOfPay = debounceAsync(updateRateOfPay_, 500)