diff --git a/src/background/constants.ts b/src/background/constants.ts new file mode 100644 index 00000000..14e648f8 --- /dev/null +++ b/src/background/constants.ts @@ -0,0 +1,15 @@ +// cSpell:ignore newtab, webui, startpage + +export const INTERNAL_PAGE_URL_PROTOCOLS = new Set([ + 'chrome:', + 'about:', + 'edge:', +]); + +export const NEW_TAB_PAGES = [ + 'about:blank', + 'chrome://newtab', + 'about:newtab', + 'edge://newtab', + 'chrome://vivaldi-webui/startpage', +]; diff --git a/src/background/container.ts b/src/background/container.ts index 3440d2f1..5c3e89c8 100644 --- a/src/background/container.ts +++ b/src/background/container.ts @@ -7,6 +7,7 @@ import { Background, TabEvents, TabState, + WindowState, SendToPopup, EventsService, Heartbeat, @@ -14,7 +15,12 @@ import { } from './services'; import { createLogger, Logger } from '@/shared/logger'; import { LOG_LEVEL } from '@/shared/defines'; -import { tFactory, type Translation } from '@/shared/helpers'; +import { + getBrowserName, + tFactory, + type BrowserName, + type Translation, +} from '@/shared/helpers'; import { MessageManager, type BackgroundToContentMessage, @@ -23,6 +29,7 @@ import { export interface Cradle { logger: Logger; browser: Browser; + browserName: BrowserName; events: EventsService; deduplicator: Deduplicator; storage: StorageService; @@ -34,6 +41,7 @@ export interface Cradle { background: Background; t: Translation; tabState: TabState; + windowState: WindowState; heartbeat: Heartbeat; } @@ -47,6 +55,7 @@ export const configureContainer = () => { container.register({ logger: asValue(logger), browser: asValue(browser), + browserName: asValue(getBrowserName(browser, navigator.userAgent)), t: asValue(tFactory(browser)), events: asClass(EventsService).singleton(), deduplicator: asClass(Deduplicator) @@ -82,6 +91,11 @@ export const configureContainer = () => { .inject(() => ({ logger: logger.getLogger('tab-state'), })), + windowState: asClass(WindowState) + .singleton() + .inject(() => ({ + logger: logger.getLogger('window-state'), + })), heartbeat: asClass(Heartbeat).singleton(), }); diff --git a/src/background/services/background.ts b/src/background/services/background.ts index 13b3c948..d224c371 100644 --- a/src/background/services/background.ts +++ b/src/background/services/background.ts @@ -8,7 +8,7 @@ import { success, } from '@/shared/helpers'; import { OpenPaymentsClientError } from '@interledger/open-payments/dist/client/error'; -import { getCurrentActiveTab, OPEN_PAYMENTS_ERRORS } from '@/background/utils'; +import { getTab, OPEN_PAYMENTS_ERRORS } from '@/background/utils'; import { PERMISSION_HOSTS } from '@/shared/defines'; import type { Cradle } from '@/background/container'; @@ -22,6 +22,7 @@ export class Background { private storage: Cradle['storage']; private logger: Cradle['logger']; private tabEvents: Cradle['tabEvents']; + private windowState: Cradle['windowState']; private sendToPopup: Cradle['sendToPopup']; private events: Cradle['events']; private heartbeat: Cradle['heartbeat']; @@ -33,6 +34,7 @@ export class Background { storage, logger, tabEvents, + windowState, sendToPopup, events, heartbeat, @@ -44,6 +46,7 @@ export class Background { storage, sendToPopup, tabEvents, + windowState, logger, events, heartbeat, @@ -93,6 +96,10 @@ export class Background { } async onStart() { + const activeWindow = await this.browser.windows.getLastFocused(); + if (activeWindow.id) { + this.windowState.setCurrentWindowId(activeWindow.id); + } await this.storage.populate(); await this.checkPermissions(); await this.scheduleResetOutOfFundsState(); @@ -118,36 +125,49 @@ export class Background { } bindWindowHandlers() { + this.browser.windows.onCreated.addListener( + this.windowState.onWindowCreated, + ); + + this.browser.windows.onRemoved.addListener( + this.windowState.onWindowRemoved, + ); + + let popupOpen = false; this.browser.windows.onFocusChanged.addListener(async () => { const windows = await this.browser.windows.getAll({ - windowTypes: ['normal', 'panel', 'popup'], + windowTypes: ['normal'], }); - windows.forEach(async (w) => { - const activeTab = ( - await this.browser.tabs.query({ windowId: w.id, active: true }) - )[0]; - if (!activeTab?.id) return; - if (this.sendToPopup.isPopupOpen) { - this.logger.debug('Popup is open, ignoring focus change'); - return; - } + const popupWasOpen = popupOpen; + popupOpen = this.sendToPopup.isPopupOpen; + if (popupWasOpen || popupOpen) { + // This is intentionally called after windows.getAll, to add a little + // delay for popup port to open + this.logger.debug('Popup is open, ignoring focus change'); + return; + } + for (const window of windows) { + const windowId = window.id!; - if (w.focused) { - this.logger.debug( - `Trying to resume monetization for window=${w.id}, activeTab=${activeTab.id} (URL: ${activeTab.url})`, - ); - void this.monetizationService.resumePaymentSessionsByTabId( - activeTab.id, + const tabIds = await this.windowState.getTabsForCurrentView(windowId); + if (window.focused) { + this.windowState.setCurrentWindowId(windowId); + this.logger.info( + `[focus change] resume monetization for window=${windowId}, tabIds=${JSON.stringify(tabIds)}`, ); + for (const tabId of tabIds) { + await this.monetizationService.resumePaymentSessionsByTabId(tabId); + } + await this.updateVisualIndicatorsForCurrentTab(); } else { - this.logger.debug( - `Trying to pause monetization for window=${w.id}, activeTab=${activeTab.id} (URL: ${activeTab.url})`, - ); - void this.monetizationService.stopPaymentSessionsByTabId( - activeTab.id, + this.logger.info( + `[focus change] stop monetization for window=${windowId}, tabIds=${JSON.stringify(tabIds)}`, ); + for (const tabId of tabIds) { + void this.monetizationService.stopPaymentSessionsByTabId(tabId); + } } - }); + } }); } @@ -166,7 +186,11 @@ export class Background { switch (message.action) { // region Popup case 'GET_CONTEXT_DATA': - return success(await this.monetizationService.getPopupData()); + return success( + await this.monetizationService.getPopupData( + await this.windowState.getCurrentTab(), + ), + ); case 'CONNECT_WALLET': await this.openPaymentsService.connectWallet(message.payload); @@ -221,6 +245,10 @@ export class Background { await getWalletInformation(message.payload.walletAddressUrl), ); + case 'TAB_FOCUSED': + await this.tabEvents.onFocussedTab(getTab(sender)); + return; + case 'START_MONETIZATION': await this.monetizationService.startPaymentSession( message.payload, @@ -242,9 +270,6 @@ export class Background { ); return; - case 'IS_WM_ENABLED': - return success(await this.storage.getWMState()); - // endregion default: @@ -269,9 +294,9 @@ export class Background { } private async updateVisualIndicatorsForCurrentTab() { - const activeTab = await getCurrentActiveTab(this.browser); + const activeTab = await this.windowState.getCurrentTab(); if (activeTab?.id) { - void this.tabEvents.updateVisualIndicators(activeTab.id, activeTab.url); + void this.tabEvents.updateVisualIndicators(activeTab); } } @@ -288,7 +313,7 @@ export class Background { this.events.on('monetization.state_update', async (tabId) => { const tab = await this.browser.tabs.get(tabId); - void this.tabEvents.updateVisualIndicators(tabId, tab?.url); + void this.tabEvents.updateVisualIndicators(tab); }); this.events.on('storage.balance_update', (balance) => diff --git a/src/background/services/index.ts b/src/background/services/index.ts index f8cf399c..d2b07f0b 100644 --- a/src/background/services/index.ts +++ b/src/background/services/index.ts @@ -4,6 +4,7 @@ export { MonetizationService } from './monetization'; export { Background } from './background'; export { TabEvents } from './tabEvents'; export { TabState } from './tabState'; +export { WindowState } from './windowState'; export { SendToPopup } from './sendToPopup'; export { EventsService } from './events'; export { Deduplicator } from './deduplicator'; diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 2c23856e..7830d0f0 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -1,19 +1,13 @@ -import type { Runtime } from 'webextension-polyfill'; +import type { Runtime, Tabs } from 'webextension-polyfill'; import { ResumeMonetizationPayload, StartMonetizationPayload, StopMonetizationPayload, } from '@/shared/messages'; import { PaymentSession } from './paymentSession'; -import { - computeRate, - getCurrentActiveTab, - getSender, - getTabId, -} from '../utils'; +import { computeRate, getSender, getTabId } from '../utils'; import { isOutOfBalanceError } from './openPayments'; import { isOkState, removeQueryParams } from '@/shared/helpers'; -import { ALLOWED_PROTOCOLS } from '@/shared/defines'; import type { AmountValue, PopupStore, Storage } from '@/shared/types'; import type { Cradle } from '../container'; @@ -25,6 +19,7 @@ export class MonetizationService { private browser: Cradle['browser']; private events: Cradle['events']; private tabState: Cradle['tabState']; + private windowState: Cradle['windowState']; private message: Cradle['message']; constructor({ @@ -35,6 +30,7 @@ export class MonetizationService { events, openPaymentsService, tabState, + windowState, message, }: Cradle) { Object.assign(this, { @@ -45,6 +41,7 @@ export class MonetizationService { browser, events, tabState, + windowState, message, }); @@ -236,7 +233,7 @@ export class MonetizationService { } async resumePaymentSessionActiveTab() { - const currentTab = await getCurrentActiveTab(this.browser); + const currentTab = await this.windowState.getCurrentTab(); if (!currentTab?.id) return; await this.resumePaymentSessionsByTabId(currentTab.id); } @@ -253,7 +250,7 @@ export class MonetizationService { } async pay(amount: string) { - const tab = await getCurrentActiveTab(this.browser); + const tab = await this.windowState.getCurrentTab(); if (!tab || !tab.id) { throw new Error('Unexpected error: could not find active tab.'); } @@ -316,7 +313,7 @@ export class MonetizationService { const tabIds = this.tabState.getAllTabs(); // Move the current active tab to the front of the array - const currentTab = await getCurrentActiveTab(this.browser); + const currentTab = await this.windowState.getCurrentTab(); if (currentTab?.id) { const idx = tabIds.indexOf(currentTab.id); if (idx !== -1) { @@ -371,7 +368,7 @@ export class MonetizationService { this.logger.debug(`All payment sessions stopped.`); } - async getPopupData(): Promise { + async getPopupData(tab: Pick): Promise { const storedData = await this.storage.get([ 'enabled', 'connected', @@ -385,37 +382,17 @@ export class MonetizationService { 'publicKey', ]); const balance = await this.storage.getBalance(); - const tab = await getCurrentActiveTab(this.browser); const { oneTimeGrant, recurringGrant, ...dataFromStorage } = storedData; - let url; - if (tab && tab.url) { - try { - const tabUrl = new URL(tab.url); - if (ALLOWED_PROTOCOLS.includes(tabUrl.protocol)) { - // Do not include search params - url = `${tabUrl.origin}${tabUrl.pathname}`; - } - } catch { - // noop - } - } - const isSiteMonetized = this.tabState.isTabMonetized(tab.id!); - const hasAllSessionsInvalid = this.tabState.tabHasAllSessionsInvalid( - tab.id!, - ); - return { ...dataFromStorage, balance: balance.total.toString(), - url, + tab: this.tabState.getPopupTabData(tab), grants: { oneTime: oneTimeGrant?.amount, recurring: recurringGrant?.amount, }, - isSiteMonetized, - hasAllSessionsInvalid, }; } diff --git a/src/background/services/tabEvents.ts b/src/background/services/tabEvents.ts index a40b72ee..e87b5ce9 100644 --- a/src/background/services/tabEvents.ts +++ b/src/background/services/tabEvents.ts @@ -1,9 +1,9 @@ import { isOkState, removeQueryParams } from '@/shared/helpers'; -import { ALLOWED_PROTOCOLS } from '@/shared/defines'; -import type { Storage, TabId } from '@/shared/types'; -import type { Browser } from 'webextension-polyfill'; +import type { PopupTabInfo, Storage, TabId } from '@/shared/types'; +import type { Browser, Tabs } from 'webextension-polyfill'; import type { Cradle } from '@/background/container'; +type IconPath = Record; const ICONS = { default: { 32: '/assets/icons/32x32/default.png', @@ -45,7 +45,7 @@ const ICONS = { 48: '/assets/icons/48x48/disabled-warn.png', 128: '/assets/icons/128x128/disabled-warn.png', }, -}; +} satisfies Record; type CallbackTab> = Parameters[0]; @@ -53,17 +53,29 @@ type CallbackTab> = export class TabEvents { private storage: Cradle['storage']; private tabState: Cradle['tabState']; + private windowState: Cradle['windowState']; private sendToPopup: Cradle['sendToPopup']; private t: Cradle['t']; private browser: Cradle['browser']; + private browserName: Cradle['browserName']; - constructor({ storage, tabState, sendToPopup, t, browser }: Cradle) { + constructor({ + storage, + tabState, + windowState, + sendToPopup, + t, + browser, + browserName, + }: Cradle) { Object.assign(this, { storage, tabState, + windowState, sendToPopup, t, browser, + browserName, }); } @@ -80,93 +92,107 @@ export class TabEvents { if (clearOverpaying) { this.tabState.clearOverpayingByTabId(tabId); } - void this.updateVisualIndicators(tabId, url); + if (!tab.id) return; + void this.updateVisualIndicators(tab); } }; - onRemovedTab: CallbackTab<'onRemoved'> = (tabId, _removeInfo) => { + onRemovedTab: CallbackTab<'onRemoved'> = (tabId, info) => { + this.windowState.removeTab(tabId, info.windowId); this.tabState.clearSessionsByTabId(tabId); this.tabState.clearOverpayingByTabId(tabId); }; onActivatedTab: CallbackTab<'onActivated'> = async (info) => { + this.windowState.addTab(info.tabId, info.windowId); + const updated = this.windowState.setCurrentTabId(info.windowId, info.tabId); + if (!updated) return; const tab = await this.browser.tabs.get(info.tabId); - await this.updateVisualIndicators(info.tabId, tab?.url); + await this.updateVisualIndicators(tab); }; onCreatedTab: CallbackTab<'onCreated'> = async (tab) => { if (!tab.id) return; - await this.updateVisualIndicators(tab.id, tab.url); + this.windowState.addTab(tab.id, tab.windowId); + await this.updateVisualIndicators(tab); }; - updateVisualIndicators = async ( - tabId: TabId, - tabUrl?: string, - isTabMonetized: boolean = tabId - ? this.tabState.isTabMonetized(tabId) - : false, - hasTabAllSessionsInvalid: boolean = tabId - ? this.tabState.tabHasAllSessionsInvalid(tabId) - : false, - ) => { - const canMonetizeTab = ALLOWED_PROTOCOLS.some((scheme) => - tabUrl?.startsWith(scheme), - ); + onFocussedTab = async (tab: Tabs.Tab) => { + if (!tab.id) return; + this.windowState.addTab(tab.id, tab.windowId); + const updated = this.windowState.setCurrentTabId(tab.windowId!, tab.id); + if (!updated) return; + await this.updateVisualIndicators(tab); + }; + + updateVisualIndicators = async (tab: Tabs.Tab) => { + const tabInfo = this.tabState.getPopupTabData(tab); + this.sendToPopup.send('SET_TAB_DATA', tabInfo); const { enabled, connected, state } = await this.storage.get([ 'enabled', 'connected', 'state', ]); - const { path, title, isMonetized } = this.getIconAndTooltip({ + const { path, title } = this.getIconAndTooltip({ enabled, connected, state, - canMonetizeTab, - isTabMonetized, - hasTabAllSessionsInvalid, + tabInfo, }); - - this.sendToPopup.send('SET_IS_MONETIZED', isMonetized); - this.sendToPopup.send('SET_ALL_SESSIONS_INVALID', hasTabAllSessionsInvalid); - await this.setIconAndTooltip(path, title, tabId); + await this.setIconAndTooltip(tabInfo.tabId, path, title); }; private setIconAndTooltip = async ( - path: (typeof ICONS)[keyof typeof ICONS], - title: string, tabId: TabId, + icon: IconPath, + title: string, ) => { - if (this.tabState.getIcon(tabId) !== path) { - this.tabState.setIcon(tabId, path); - await this.browser.action.setIcon({ path, tabId }); - } + await this.setIcon(tabId, icon); await this.browser.action.setTitle({ title, tabId }); }; + private async setIcon(tabId: TabId, icon: IconPath) { + if (this.browserName === 'edge') { + // Edge has split-view, and if we specify a tabId, it will only set the + // icon for the left-pane when split-view is open. So, we ignore the + // tabId. As it's inefficient, we do it only for Edge. + // We'd have set this for a windowId, but that's not supported in Edge/Chrome + await this.browser.action.setIcon({ path: icon }); + return; + } + + if (this.tabState.getIcon(tabId) !== icon) { + this.tabState.setIcon(tabId, icon); // memoize + await this.browser.action.setIcon({ path: icon, tabId }); + } + } + private getIconAndTooltip({ enabled, connected, state, - canMonetizeTab, - isTabMonetized, - hasTabAllSessionsInvalid, + tabInfo, }: { enabled: Storage['enabled']; connected: Storage['connected']; state: Storage['state']; - canMonetizeTab: boolean; - isTabMonetized: boolean; - hasTabAllSessionsInvalid: boolean; + tabInfo: PopupTabInfo; }) { let title = this.t('appName'); let iconData = ICONS.default; - if (!connected || !canMonetizeTab) { + if (!connected) { // use defaults - } else if (!isOkState(state) || hasTabAllSessionsInvalid) { + } else if (!isOkState(state) || tabInfo.status === 'all_sessions_invalid') { iconData = enabled ? ICONS.enabled_warn : ICONS.disabled_warn; const tabStateText = this.t('icon_state_actionRequired'); title = `${title} - ${tabStateText}`; + } else if ( + tabInfo.status !== 'monetized' && + tabInfo.status !== 'no_monetization_links' + ) { + // use defaults } else { + const isTabMonetized = tabInfo.status === 'monetized'; if (enabled) { iconData = isTabMonetized ? ICONS.enabled_hasLinks @@ -182,10 +208,6 @@ export class TabEvents { title = `${title} - ${tabStateText}`; } - return { - path: iconData, - isMonetized: isTabMonetized, - title, - }; + return { path: iconData, title }; } } diff --git a/src/background/services/tabState.ts b/src/background/services/tabState.ts index b218cb1e..27831e9d 100644 --- a/src/background/services/tabState.ts +++ b/src/background/services/tabState.ts @@ -1,7 +1,11 @@ +import type { Tabs } from 'webextension-polyfill'; import type { MonetizationEventDetails } from '@/shared/messages'; -import type { TabId } from '@/shared/types'; +import type { PopupTabInfo, TabId } from '@/shared/types'; import type { PaymentSession } from './paymentSession'; import type { Cradle } from '@/background/container'; +import { removeQueryParams } from '@/shared/helpers'; +import { ALLOWED_PROTOCOLS } from '@/shared/defines'; +import { isBrowserInternalPage, isBrowserNewTabPage } from '@/background/utils'; type State = { monetizationEvent: MonetizationEventDetails; @@ -119,6 +123,46 @@ export class TabState { return [...this.sessions.values()].flatMap((s) => [...s.values()]); } + getPopupTabData(tab: Pick): PopupTabInfo { + if (!tab.id) { + throw new Error('Tab does not have an ID'); + } + + let tabUrl: URL | null = null; + try { + tabUrl = new URL(tab.url ?? ''); + } catch { + // noop + } + + let url = ''; + if (tabUrl && ALLOWED_PROTOCOLS.includes(tabUrl.protocol)) { + // Do not include search params + url = removeQueryParams(tabUrl.href); + } + + let status: PopupTabInfo['status'] = 'no_monetization_links'; + if (!tabUrl) { + status = 'unsupported_scheme'; + } else if (!ALLOWED_PROTOCOLS.includes(tabUrl.protocol)) { + if (tabUrl && isBrowserInternalPage(tabUrl)) { + if (isBrowserNewTabPage(tabUrl)) { + status = 'new_tab'; + } else { + status = 'internal_page'; + } + } else { + status = 'unsupported_scheme'; + } + } else if (this.tabHasAllSessionsInvalid(tab.id)) { + status = 'all_sessions_invalid'; + } else if (this.isTabMonetized(tab.id)) { + status = 'monetized'; + } + + return { tabId: tab.id, url, status }; + } + getIcon(tabId: TabId) { return this.currentIcon.get(tabId); } diff --git a/src/background/services/windowState.ts b/src/background/services/windowState.ts new file mode 100644 index 00000000..3b55d43c --- /dev/null +++ b/src/background/services/windowState.ts @@ -0,0 +1,124 @@ +import type { Browser } from 'webextension-polyfill'; +import type { TabId, WindowId } from '@/shared/types'; +import type { Cradle } from '@/background/container'; +import { getCurrentActiveTab } from '@/background/utils'; + +type CallbackWindow< + T extends Extract, +> = Parameters[0]; + +export class WindowState { + private browser: Cradle['browser']; + private message: Cradle['message']; + + private currentWindowId: WindowId; + private currentTab = new Map(); + /** + * In Edge's split view, `browser.tabs.query({ windowId })` doesn't return + * all tabs. So, we maintain the set of tabs per window. + */ + private tabs = new Map>(); + + constructor({ browser, message }: Cradle) { + Object.assign(this, { browser, message }); + } + + setCurrentWindowId(windowId: WindowId) { + if (this.currentWindowId === windowId) { + return false; + } + this.currentWindowId = windowId; + return true; + } + + getCurrentWindowId() { + return this.currentWindowId; + } + + addTab(tabId: TabId, windowId: WindowId = this.getCurrentWindowId()) { + const tabs = this.tabs.get(windowId); + if (tabs) { + const prevSize = tabs.size; + tabs.add(tabId); + return prevSize !== tabs.size; + } else { + this.tabs.set(windowId, new Set([tabId])); + return true; + } + } + + removeTab(tabId: TabId, windowId: WindowId = this.getCurrentWindowId()) { + return this.tabs.get(windowId)?.delete(tabId) ?? false; + } + + getTabs(windowId: WindowId = this.getCurrentWindowId()): TabId[] { + return Array.from(this.tabs.get(windowId) ?? []); + } + + /** + * For given window, get the list of tabs that are currently in view. + * + * Browsers like Edge, Vivaldi allow having multiple tabs in same "view" + * (split-view, tab-tiling). We can use this data to resume/pause monetization + * for multiple tabs on window focus change, not just the one active tab that + * browser APIs return. + */ + async getTabsForCurrentView( + windowId: WindowId = this.getCurrentWindowId(), + ): Promise { + const TOP_FRAME_ID = 0; + const tabs = this.getTabs(windowId); + const responses = await Promise.all( + tabs.map((tabId) => + this.message + .sendToTab(tabId, TOP_FRAME_ID, 'IS_TAB_IN_VIEW', undefined) + .then((r) => (r.success ? r.payload : null)) + .catch(() => null), + ), + ); + return tabs.filter((_, i) => responses[i]); + } + + setCurrentTabId(windowId: WindowId, tabId: TabId) { + const existing = this.getCurrentTabId(windowId); + if (existing === tabId) return false; + this.currentTab.set(windowId, tabId); + return true; + } + + getCurrentTabId(windowId: WindowId = this.getCurrentWindowId()) { + return this.currentTab.get(windowId); + } + + async getCurrentTab(windowId: WindowId = this.getCurrentWindowId()) { + const tabId = this.getCurrentTabId(windowId); + const tab = tabId + ? await this.browser.tabs.get(tabId) + : await getCurrentActiveTab(this.browser); + return tab; + } + + onWindowCreated: CallbackWindow<'onCreated'> = async (window) => { + if (window.type && window.type !== 'normal') { + return; + } + + const prevWindowId = this.getCurrentWindowId(); + const prevTabId = this.getCurrentTabId(prevWindowId); + // if the window was created with a tab (like move tab to new window), + // remove tab from previous window + if (prevWindowId && window.id !== prevWindowId) { + if (prevTabId) { + const tab = await this.browser.tabs.get(prevTabId); + if (tab.windowId !== prevWindowId) { + this.removeTab(prevTabId, prevWindowId); + } + } + } + }; + + onWindowRemoved: CallbackWindow<'onRemoved'> = (windowId) => { + this.currentTab.delete(windowId); + this.tabs.delete(windowId); + }; +} diff --git a/src/background/utils.ts b/src/background/utils.ts index 34b82a25..0e08fc05 100644 --- a/src/background/utils.ts +++ b/src/background/utils.ts @@ -1,6 +1,12 @@ -import type { AmountValue, GrantDetails, WalletAmount } from '@/shared/types'; -import type { Browser, Runtime, Tabs } from 'webextension-polyfill'; +import type { + AmountValue, + GrantDetails, + Tab, + WalletAmount, +} from '@/shared/types'; +import type { Browser, Runtime } from 'webextension-polyfill'; import { DEFAULT_SCALE, EXCHANGE_RATES_URL } from './config'; +import { INTERNAL_PAGE_URL_PROTOCOLS, NEW_TAB_PAGES } from './constants'; import { notNullOrUndef } from '@/shared/helpers'; export const getCurrentActiveTab = async (browser: Browser) => { @@ -81,8 +87,8 @@ export const getTabId = (sender: Runtime.MessageSender): number => { return notNullOrUndef(notNullOrUndef(sender.tab, 'sender.tab').id, 'tab.id'); }; -export const getTab = (sender: Runtime.MessageSender): Tabs.Tab => { - return notNullOrUndef(notNullOrUndef(sender.tab, 'sender.tab'), 'tab'); +export const getTab = (sender: Runtime.MessageSender): Tab => { + return notNullOrUndef(notNullOrUndef(sender.tab, 'sender.tab'), 'tab') as Tab; }; export const getSender = (sender: Runtime.MessageSender) => { @@ -92,6 +98,14 @@ export const getSender = (sender: Runtime.MessageSender) => { return { tabId, frameId, url: sender.url }; }; +export const isBrowserInternalPage = (url: URL) => { + return INTERNAL_PAGE_URL_PROTOCOLS.has(url.protocol); +}; + +export const isBrowserNewTabPage = (url: URL) => { + return NEW_TAB_PAGES.some((e) => url.href.startsWith(e)); +}; + export const computeRate = (rate: string, sessionsCount: number): AmountValue => (BigInt(rate) / BigInt(sessionsCount)).toString(); diff --git a/src/content/services/contentScript.ts b/src/content/services/contentScript.ts index b5df9572..7a7cf996 100644 --- a/src/content/services/contentScript.ts +++ b/src/content/services/contentScript.ts @@ -1,6 +1,6 @@ import type { ToContentMessage } from '@/shared/messages'; import type { Cradle } from '@/content/container'; -import { failure } from '@/shared/helpers'; +import { failure, success } from '@/shared/helpers'; export class ContentScript { private browser: Cradle['browser']; @@ -54,6 +54,8 @@ export class ContentScript { message.payload, ); return; + case 'IS_TAB_IN_VIEW': + return success(document.visibilityState === 'visible'); default: return; } diff --git a/src/content/services/monetizationLinkManager.ts b/src/content/services/monetizationLinkManager.ts index 4a4770bb..682f0a5f 100644 --- a/src/content/services/monetizationLinkManager.ts +++ b/src/content/services/monetizationLinkManager.ts @@ -95,6 +95,7 @@ export class MonetizationLinkManager extends EventEmitter { this.onDocumentVisibilityChange, ); this.window.removeEventListener('message', this.onWindowMessage); + this.window.removeEventListener('focus', this.onFocus); } /** @@ -105,6 +106,8 @@ export class MonetizationLinkManager extends EventEmitter { 'visibilitychange', this.onDocumentVisibilityChange, ); + this.onFocus(); + this.window.addEventListener('focus', this.onFocus); if (!this.isTopFrame && this.isFirstLevelFrame) { this.window.addEventListener('message', this.onWindowMessage); @@ -349,6 +352,12 @@ export class MonetizationLinkManager extends EventEmitter { } }; + private onFocus = async () => { + if (this.document.hasFocus()) { + await this.message.send('TAB_FOCUSED'); + } + }; + private async onWholeDocumentObserved(records: MutationRecord[]) { const stopMonetizationPayload: StopMonetizationPayload = []; diff --git a/src/popup/components/PayWebsiteForm.tsx b/src/popup/components/PayWebsiteForm.tsx index 3be8cef7..312f9330 100644 --- a/src/popup/components/PayWebsiteForm.tsx +++ b/src/popup/components/PayWebsiteForm.tsx @@ -26,7 +26,7 @@ const BUTTON_STATE = { export const PayWebsiteForm = () => { const message = useMessage(); const { - state: { walletAddress, url }, + state: { walletAddress, tab }, } = usePopupState(); const [buttonState, setButtonState] = React.useState('idle'); @@ -84,7 +84,7 @@ export const PayWebsiteForm = () => { addOn={getCurrencySymbol(walletAddress.assetCode)} label={

- Pay {url} + Pay {tab.url}

} placeholder="0.00" diff --git a/src/popup/lib/context.tsx b/src/popup/lib/context.tsx index a7148d77..66905f3c 100644 --- a/src/popup/lib/context.tsx +++ b/src/popup/lib/context.tsx @@ -73,12 +73,10 @@ const reducer = (state: PopupState, action: ReducerActions): PopupState => { return { ...state, rateOfPay: action.data.rateOfPay }; case 'SET_STATE': return { ...state, state: action.data.state }; - case 'SET_IS_MONETIZED': - return { ...state, isSiteMonetized: action.data }; + case 'SET_TAB_DATA': + return { ...state, tab: action.data }; case 'SET_BALANCE': return { ...state, balance: action.data.total }; - case 'SET_ALL_SESSIONS_INVALID': - return { ...state, hasAllSessionsInvalid: action.data }; default: return state; } @@ -113,8 +111,7 @@ export function PopupContextProvider({ children }: PopupContextProviderProps) { switch (message.type) { case 'SET_BALANCE': case 'SET_STATE': - case 'SET_IS_MONETIZED': - case 'SET_ALL_SESSIONS_INVALID': + case 'SET_TAB_DATA': return dispatch(message); } }); diff --git a/src/popup/pages/Home.tsx b/src/popup/pages/Home.tsx index 908b0682..b92196a5 100644 --- a/src/popup/pages/Home.tsx +++ b/src/popup/pages/Home.tsx @@ -19,14 +19,12 @@ export const Component = () => { const { state: { enabled, - isSiteMonetized, rateOfPay, minRateOfPay, maxRateOfPay, balance, walletAddress, - url, - hasAllSessionsInvalid, + tab, }, dispatch, } = usePopupState(); @@ -65,12 +63,12 @@ export const Component = () => { dispatch({ type: 'TOGGLE_WM', data: {} }); }; - if (!isSiteMonetized) { - return ; - } - - if (hasAllSessionsInvalid) { - return ; + if (tab.status !== 'monetized') { + if (tab.status === 'all_sessions_invalid') { + return ; + } else { + return ; + } } return ( @@ -113,7 +111,7 @@ export const Component = () => {
- {url ? : null} + {tab.url ? : null} ); }; diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index b78d0196..af490c9f 100644 --- a/src/shared/helpers.ts +++ b/src/shared/helpers.ts @@ -324,3 +324,28 @@ export const getNextOccurrence = ( return date; }; + +export type BrowserName = 'chrome' | 'edge' | 'firefox' | 'unknown'; + +export const getBrowserName = ( + browser: Browser, + userAgent: string, +): BrowserName => { + const url = browser.runtime.getURL(''); + if (url.startsWith('moz-extension://')) { + return 'firefox'; + } + if (url.startsWith('extension://')) { + // works only in Playwright? + return 'edge'; + } + + if (url.startsWith('chrome-extension://')) { + if (userAgent.includes('Edg/')) { + return 'edge'; + } + return 'chrome'; + } + + return 'unknown'; +}; diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 2c58699f..f14daa0b 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -176,6 +176,10 @@ export type ContentToBackgroundMessage = { input: GetWalletAddressInfoPayload; output: WalletAddress; }; + TAB_FOCUSED: { + input: never; + output: never; + }; STOP_MONETIZATION: { input: StopMonetizationPayload; output: never; @@ -188,10 +192,6 @@ export type ContentToBackgroundMessage = { input: ResumeMonetizationPayload; output: never; }; - IS_WM_ENABLED: { - input: never; - output: boolean; - }; }; // #endregion @@ -224,6 +224,10 @@ export type BackgroundToContentMessage = { input: MonetizationEventPayload; output: never; }; + IS_TAB_IN_VIEW: { + input: undefined; + output: boolean; + }; }; export type ToContentMessage = { @@ -240,9 +244,8 @@ export const BACKGROUND_TO_POPUP_CONNECTION_NAME = 'popup'; // These methods are fire-and-forget, nothing is returned. export interface BackgroundToPopupMessagesMap { SET_BALANCE: Record<'recurring' | 'oneTime' | 'total', AmountValue>; - SET_IS_MONETIZED: boolean; + SET_TAB_DATA: PopupState['tab']; SET_STATE: { state: Storage['state']; prevState: Storage['state'] }; - SET_ALL_SESSIONS_INVALID: boolean; } export type BackgroundToPopupMessage = { diff --git a/src/shared/types.ts b/src/shared/types.ts index fc5ff6a8..c34be612 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -85,6 +85,29 @@ export interface Storage { } export type StorageKey = keyof Storage; +export type PopupTabInfo = { + tabId: TabId; + url: string; + status: + | never // just added for code formatting + /** Happy state */ + | 'monetized' + /** No monetization links or all links disabled */ + | 'no_monetization_links' + /** New tab */ + | 'new_tab' + /** Browser internal pages */ + | 'internal_page' + /** Not https:// */ + | 'unsupported_scheme' + /** + * All wallet addresses belong to wallets that are not peered with the + * connected wallet, or cannot receive payments for some other reason. + */ + | 'all_sessions_invalid' + | never; // just added for code formatting +}; + export type PopupStore = Omit< Storage, | 'version' @@ -95,17 +118,19 @@ export type PopupStore = Omit< | 'oneTimeGrant' > & { balance: AmountValue; - isSiteMonetized: boolean; - url: string | undefined; + tab: PopupTabInfo; grants?: Partial<{ oneTime: OneTimeGrant['amount']; recurring: RecurringGrant['amount']; }>; - hasAllSessionsInvalid: boolean; }; export type DeepNonNullable = { [P in keyof T]?: NonNullable; }; +export type RequiredFields = T & Required>; + +export type Tab = RequiredFields; export type TabId = NonNullable; +export type WindowId = NonNullable;