import { VERSION } from './constants.js' import { standardErrors } from './error' import type { ConfigMessage, Message, MessageID } from './message' import { closePopup, openPopup } from './popup.js' /** * Communicates with a popup window for Coinbase keys.coinbase.com (or another url) * to send and receive messages. * * This class is responsible for opening a popup window, posting messages to it, * and listening for responses. * * It also handles cleanup of event listeners and the popup window itself when necessary. */ export class Communicator { private readonly url: URL private popup: Window | null = null private listeners = new Map<(_: MessageEvent) => void, { reject: (_: Error) => void }>() private debug = false private onDisconnectCallback?: () => void private popupCheckInterval?: number private isDisconnecting = false constructor({ url, debug = false, onDisconnect }: { url: string; debug?: boolean; onDisconnect?: () => void }) { this.url = new URL(url) this.debug = debug this.onDisconnectCallback = onDisconnect } /** * Posts a message to the popup window */ postMessage = async (message: Message) => { const popup = await this.waitForPopupLoaded() popup.postMessage(message, this.url.origin) } /** * Posts a request to the popup window and waits for a response */ postRequestAndWaitForResponse = async (request: Message & { id: MessageID }): Promise => { const responsePromise = this.onMessage(({ requestId }) => requestId === request.id) this.postMessage(request) return await responsePromise } /** * Listens for messages from the popup window that match a given predicate. */ onMessage = async (predicate: (_: Partial) => boolean): Promise => { return new Promise((resolve, reject) => { const listener = (event: MessageEvent) => { if (event.origin !== this.url.origin) return // origin validation const message = event.data if (predicate(message)) { resolve(message) window.removeEventListener('message', listener) this.listeners.delete(listener) } } window.addEventListener('message', listener) this.listeners.set(listener, { reject }) }) } private log(...args: any[]): void { if (this.debug) { console.log('[Communicator]', ...args) } } /** * Starts polling to check if popup is manually closed */ private startPopupPolling = () => { if (this.popupCheckInterval) { window.clearInterval(this.popupCheckInterval) } this.popupCheckInterval = window.setInterval(() => { if (this.popup && this.popup.closed && !this.isDisconnecting) { this.log('Popup manually closed by user') this.disconnect() } }, 1000) // Check every second } /** * Stops polling for popup closure */ private stopPopupPolling = () => { if (this.popupCheckInterval) { window.clearInterval(this.popupCheckInterval) this.popupCheckInterval = undefined } } /** * Closes the popup, rejects all requests and clears the listeners */ disconnect = () => { if (this.isDisconnecting) return this.isDisconnecting = true // Stop polling first this.stopPopupPolling() // Note: keys popup handles closing itself. this is a fallback. closePopup(this.popup) this.popup = null this.listeners.forEach(({ reject }, listener) => { reject(standardErrors.provider.userRejectedRequest()) window.removeEventListener('message', listener) }) this.listeners.clear() this.onDisconnectCallback?.() // Don't reset flag - prevents duplicate calls from PopupUnload message } /** * Waits for the popup window to fully load and then sends a version message. */ waitForPopupLoaded = async (): Promise => { if (this.popup && !this.popup.closed) { // In case the user un-focused the popup between requests, focus it again this.popup.focus() return this.popup } // Reset disconnection flag when opening new popup this.isDisconnecting = false this.popup = await openPopup(this.url) // Start polling to detect manual closure this.startPopupPolling() this.onMessage(({ event }) => event === 'PopupUnload') .then(() => { this.disconnect() }) .catch(() => {}) return this.onMessage(({ event }) => event === 'PopupLoaded') .then(message => { this.postMessage({ requestId: message.id, data: { version: VERSION, location: window.location.toString(), }, }) }) .then(() => { if (!this.popup) throw standardErrors.rpc.internal() return this.popup }) } }