From 13d397d370262bfd83b79a71d54145cbbb4c4017 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Tue, 18 Jun 2024 15:57:04 +0530 Subject: [PATCH] fix(content): dispatch MonetizationEvent instead of CustomEvent (#346) 1. Dispatch `MonetizationEvent` instead of `CustomEvent` with detail 2. Keep supporting `event.detail` access for backward compat, but add a warning on accessing for first time. 3. Rename internal event (for communication between content scripts) from `monetization-v2` to `__wm_ext_monetization`, along with `onmonetization-attribute-changed` to `__wm_ext_onmonetization_attr_change`. 4. Rename contentScript to polyfill 5. Turn contentScript code from a string to regular TS file, so we can typecheck it like rest of code (added typescript fixes too). 6. Now, MonetizationEvent interface is available on global. --- src/background/services/paymentSession.ts | 21 ++- src/content/polyfill.ts | 125 ++++++++++++++++++ src/content/services/contentScript.ts | 16 ++- .../services/monetizationTagManager.ts | 16 +-- src/content/static/index.ts | 13 -- src/content/static/polyfill.ts | 89 ------------- src/content/utils.ts | 2 +- src/manifest.json | 15 +-- src/shared/messages.ts | 8 +- webpack/config.ts | 4 +- 10 files changed, 175 insertions(+), 134 deletions(-) create mode 100644 src/content/polyfill.ts delete mode 100644 src/content/static/index.ts delete mode 100644 src/content/static/polyfill.ts diff --git a/src/background/services/paymentSession.ts b/src/background/services/paymentSession.ts index 4d84d8ff..8e5e6db2 100644 --- a/src/background/services/paymentSession.ts +++ b/src/background/services/paymentSession.ts @@ -9,6 +9,7 @@ import { import { OpenPaymentsClientError } from '@interledger/open-payments/dist/client' import { sendMonetizationEvent } from '../lib/messages' import { convert, sleep } from '@/shared/helpers' +import { transformBalance } from '@/popup/lib/utils' const DEFAULT_INTERVAL_MS = 1000 const HOUR_MS = 3600 * 1000 @@ -173,8 +174,14 @@ export class PaymentSession { frameId: this.frameId, payload: { requestId: this.requestId, - details: { - receiveAmount, + detail: { + amountSent: { + currency: receiveAmount.assetCode, + value: transformBalance( + receiveAmount.value, + receiveAmount.assetScale + ) + }, incomingPayment, paymentPointer: this.receiver.id } @@ -283,8 +290,14 @@ export class PaymentSession { frameId: this.frameId, payload: { requestId: this.requestId, - details: { - receiveAmount, + detail: { + amountSent: { + currency: receiveAmount.assetCode, + value: transformBalance( + receiveAmount.value, + receiveAmount.assetScale + ) + }, incomingPayment, paymentPointer: this.receiver.id } diff --git a/src/content/polyfill.ts b/src/content/polyfill.ts new file mode 100644 index 00000000..acd8c650 --- /dev/null +++ b/src/content/polyfill.ts @@ -0,0 +1,125 @@ +import type { MonetizationEventPayload } from '@/shared/messages' +;(function () { + const handlers = new WeakMap() + const attributes: PropertyDescriptor & ThisType = { + enumerable: true, + configurable: false, + get() { + return handlers.get(this) || null + }, + set(val) { + const listener = handlers.get(this) + if (listener && listener === val) { + // nothing to do here ? + return + } + const removeAnyExisting = () => { + if (listener) { + this.removeEventListener('monetization', listener) + } + } + if (val == null /* OR undefined*/) { + handlers.delete(this) + removeAnyExisting() + } else if (typeof val === 'function') { + removeAnyExisting() + this.addEventListener('monetization', val) + handlers.set(this, val) + } else { + throw new Error('val must be a function, got ' + typeof val) + } + } + } + + const supportsOriginal = DOMTokenList.prototype.supports + const supportsMonetization = Symbol.for('link-supports-monetization') + DOMTokenList.prototype.supports = function (token) { + // @ts-expect-error: polyfilled + if (this[supportsMonetization] && token === 'monetization') { + return true + } else { + return supportsOriginal.call(this, token) + } + } + + const relList = Object.getOwnPropertyDescriptor( + HTMLLinkElement.prototype, + 'relList' + )! + const relListGetOriginal = relList.get! + + relList.get = function () { + const val = relListGetOriginal.call(this) + val[supportsMonetization] = true + return val + } + + Object.defineProperty(HTMLLinkElement.prototype, 'relList', relList) + Object.defineProperty(HTMLElement.prototype, 'onmonetization', attributes) + Object.defineProperty(Window.prototype, 'onmonetization', attributes) + Object.defineProperty(Document.prototype, 'onmonetization', attributes) + + let eventDetailDeprecationEmitted = false + class MonetizationEvent extends Event { + public readonly amountSent: PaymentCurrencyAmount + public readonly incomingPayment: string + public readonly paymentPointer: string + + constructor( + type: 'monetization', + eventInitDict: MonetizationEventPayload['detail'] + ) { + super(type, { bubbles: true }) + const { amountSent, incomingPayment, paymentPointer } = eventInitDict + this.amountSent = amountSent + this.incomingPayment = incomingPayment + this.paymentPointer = paymentPointer + } + + get [Symbol.toStringTag]() { + return 'MonetizationEvent' + } + + get detail() { + if (!eventDetailDeprecationEmitted) { + const msg = `MonetizationEvent.detail is deprecated. Access attributes directly instead.` + // eslint-disable-next-line no-console + console.warn(msg) + eventDetailDeprecationEmitted = true + } + const { amountSent, incomingPayment, paymentPointer } = this + return { amountSent, incomingPayment, paymentPointer } + } + } + + // @ts-expect-error: we're defining this now + window.MonetizationEvent = MonetizationEvent + + window.addEventListener( + '__wm_ext_monetization', + (event: CustomEvent) => { + if (!(event.target instanceof HTMLLinkElement)) return + if (!event.target.isConnected) return + + const monetizationTag = event.target + monetizationTag.dispatchEvent( + new MonetizationEvent('monetization', event.detail) + ) + }, + { capture: true } + ) + + window.addEventListener( + '__wm_ext_onmonetization_attr_change', + (event: CustomEvent<{ attribute?: string }>) => { + if (!event.target) return + + const { attribute } = event.detail + // @ts-expect-error: we're defining this now + event.target.onmonetization = attribute + ? new Function(attribute).bind(event.target) + : null + }, + { capture: true } + ) +})() diff --git a/src/content/services/contentScript.ts b/src/content/services/contentScript.ts index e1549613..d405126a 100644 --- a/src/content/services/contentScript.ts +++ b/src/content/services/contentScript.ts @@ -22,7 +22,8 @@ export class ContentScript { this.bindMessageHandler() } - start() { + async start() { + await this.injectPolyfill() if (this.isFirstLevelFrame) { this.logger.info('Content script started') @@ -58,4 +59,17 @@ export class ContentScript { } ) } + + // TODO: When Firefox has good support for `world: MAIN`, inject this directly + // via manifest.json https://bugzilla.mozilla.org/show_bug.cgi?id=1736575 + async injectPolyfill() { + const document = this.window.document + const script = document.createElement('script') + script.src = this.browser.runtime.getURL('polyfill/polyfill.js') + await new Promise((resolve) => { + script.addEventListener('load', () => resolve(), { once: true }) + document.documentElement.appendChild(script) + }) + script.remove() + } } diff --git a/src/content/services/monetizationTagManager.ts b/src/content/services/monetizationTagManager.ts index 0fa0838c..63ad91af 100644 --- a/src/content/services/monetizationTagManager.ts +++ b/src/content/services/monetizationTagManager.ts @@ -65,16 +65,16 @@ export class MonetizationTagManager extends EventEmitter { } } - dispatchMonetizationEvent({ requestId, details }: MonetizationEventPayload) { + dispatchMonetizationEvent({ requestId, detail }: MonetizationEventPayload) { this.monetizationTags.forEach((tagDetails, tag) => { if (tagDetails.requestId !== requestId) return - const customEvent = new CustomEvent('monetization', { - bubbles: true, - detail: mozClone(details, this.document) - }) - - tag.dispatchEvent(customEvent) + tag.dispatchEvent( + new CustomEvent('__wm_ext_monetization', { + detail: mozClone(detail, this.document), + bubbles: true + }) + ) }) return } @@ -296,7 +296,7 @@ export class MonetizationTagManager extends EventEmitter { if (!attribute && !changeDetected) return - const customEvent = new CustomEvent('onmonetization-attr-changed', { + const customEvent = new CustomEvent('__wm_ext_onmonetization_attr_change', { bubbles: true, detail: mozClone({ attribute }, this.document) }) diff --git a/src/content/static/index.ts b/src/content/static/index.ts deleted file mode 100644 index 42b1ade1..00000000 --- a/src/content/static/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { wm2Polyfill } from './polyfill' - -function inject(configure: (_script: HTMLScriptElement) => void) { - const script = document.createElement('script') - configure(script) - document.documentElement.appendChild(script) - document.documentElement.removeChild(script) -} - -// eslint-disable-next-line @typescript-eslint/no-extra-semi -;(function injectCode(code: string) { - inject((script) => (script.innerHTML = code)) -})(wm2Polyfill) diff --git a/src/content/static/polyfill.ts b/src/content/static/polyfill.ts deleted file mode 100644 index 30318684..00000000 --- a/src/content/static/polyfill.ts +++ /dev/null @@ -1,89 +0,0 @@ -// language=JavaScript -export const wm2Polyfill = ` - const handlers = new WeakMap() - var attributes = { - enumerable: true, - configurable: false, - get() { - return handlers.get(this) || null - }, - set(val) { - const listener = handlers.get(this) - if (listener && listener === val) { - // nothing to do here ? - return - } - const removeAnyExisting = () => { - if (listener) { - this.removeEventListener('monetization', listener) - } - } - if (val == null /* OR undefined*/) { - handlers.delete(this) - removeAnyExisting() - } else if (typeof val === 'function') { - removeAnyExisting() - this.addEventListener('monetization', val) - handlers.set(this, val) - } else { - throw new Error("val must be a function, got " + typeof val) - } - } - } - - const supportsOriginal = DOMTokenList.prototype.supports - const supportsMonetization = Symbol.for('link-supports-monetization') - DOMTokenList.prototype.supports = function(token) { - if (this[supportsMonetization] && token === 'monetization') { - return true - } else { - return supportsOriginal.call(this, token) - } - } - - const relList = Object.getOwnPropertyDescriptor(HTMLLinkElement.prototype, 'relList') - const relListGetOriginal = relList.get - - relList.get = function() { - const val = relListGetOriginal.call(this) - val[supportsMonetization] = true - return val - } - - Object.defineProperty(HTMLLinkElement.prototype, 'relList', relList) - Object.defineProperty(HTMLElement.prototype, 'onmonetization', attributes) - Object.defineProperty(Window.prototype, 'onmonetization', attributes) - Object.defineProperty(Document.prototype, 'onmonetization', attributes) - - // - class MonetizationEvent extends Event { - constructor(type, details) { - super('monetization', { bubbles: true }) - Object.assign(this, details) - } - - get [Symbol.toStringTag]() { - return 'MonetizationEvent' - } - } - - window.MonetizationEvent = MonetizationEvent - - window.addEventListener('monetization-v2', (event) => { - - const monetizationTag = document.querySelector('link[rel="monetization"]'); - const monetizationEvent = new MonetizationEvent('monetization', event.detail) - monetizationTag.dispatchEvent(monetizationEvent) - }, { capture: true, bubble: true }) - window.addEventListener('onmonetization-attr-changed', (event) => { - const { attribute } = event.detail - if (attribute) { - // TODO:WM2 what are the CSP issues here? - // is there any alternative ?? - // Well, people could just use - event.target.onmonetization = new Function(attribute).bind(event.target) - } else { - event.target.onmonetization = null - } - }, { capture: true }) -` diff --git a/src/content/utils.ts b/src/content/utils.ts index 88024494..a87ca281 100644 --- a/src/content/utils.ts +++ b/src/content/utils.ts @@ -63,7 +63,7 @@ try { cloneIntoRef = undefined } -export function mozClone(obj: unknown, document: Document) { +export function mozClone(obj: T, document: Document) { return cloneIntoRef ? cloneIntoRef(obj, document.defaultView) : obj } diff --git a/src/manifest.json b/src/manifest.json index 78d1a41f..b2d71bd7 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -13,13 +13,7 @@ { "matches": ["https://*/*"], "js": ["content/content.js"], - "all_frames": true - }, - { "run_at": "document_start", - "matches": ["https://*/*"], - "js": ["contentStatic/contentStatic.js"], - "world": "MAIN", "all_frames": true } ], @@ -33,14 +27,7 @@ }, "web_accessible_resources": [ { - "resources": [ - "assets/*", - "content/*", - "options/*", - "popup/*", - "background/*", - "specs/*" - ], + "resources": ["assets/*", "polyfill/*", "specs/*"], "matches": [""] } ], diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 85a68b18..a665bc72 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -1,4 +1,4 @@ -import { WalletAddress } from '@interledger/open-payments' +import type { WalletAddress, OutgoingPayment } from '@interledger/open-payments' import { type Browser } from 'webextension-polyfill' export interface SuccessResponse { @@ -131,7 +131,11 @@ export enum BackgroundToContentAction { export interface MonetizationEventPayload { requestId: string - details: any + detail: { + amountSent: PaymentCurrencyAmount + incomingPayment: OutgoingPayment['receiver'] + paymentPointer: WalletAddress['id'] + } } export interface EmitToggleWMPayload { diff --git a/webpack/config.ts b/webpack/config.ts index 40ef0025..d520c6f1 100644 --- a/webpack/config.ts +++ b/webpack/config.ts @@ -84,8 +84,8 @@ export const mainConfig: Configuration = { entry: { popup: [path.resolve(ROOT_DIR, `${DIRECTORIES.SRC}/popup/index.tsx`)], content: [path.resolve(ROOT_DIR, `${DIRECTORIES.SRC}/content/index.ts`)], - contentStatic: [ - path.resolve(ROOT_DIR, `${DIRECTORIES.SRC}/content/static/index.ts`) + polyfill: [ + path.resolve(ROOT_DIR, `${DIRECTORIES.SRC}/content/polyfill.ts`) ], background: [ path.resolve(ROOT_DIR, `${DIRECTORIES.SRC}/background/index.ts`)