diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000000..9ce97a1fe6 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,18 @@ +name: Setup +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + registry-url: https://registry.npmjs.org + cache: yarn + + # For provenance https://docs.npmjs.com/generating-provenance-statements#prerequisites + - name: Install npm 9.5 + run: npm install -g npm@^9.5.0 + shell: bash + + - name: Install node modules + run: yarn install --frozen-lockfile + shell: bash diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/playground.yml similarity index 100% rename from .github/workflows/build-and-deploy.yml rename to .github/workflows/playground.yml diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml new file mode 100644 index 0000000000..fa26a140a2 --- /dev/null +++ b/.github/workflows/release-canary.yml @@ -0,0 +1,42 @@ +name: Release (Canary) +on: + workflow_dispatch: + +jobs: + canary: + name: Release canary + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + pull-requests: write + id-token: write + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: 'Setup' + uses: ./.github/actions/setup + + - name: Set version + run: npm --no-git-tag-version version $(node -p "require('./packages/wallet-sdk/package.json').version")-canary.$(date +'%Y%m%d') -w packages/wallet-sdk + + # Build the package + - name: Prebuild + run: yarn workspace @coinbase/wallet-sdk prebuild + + - name: Build Packages + shell: bash + run: yarn build:packages + + - name: Set deployment token + run: npm config set '//registry.npmjs.org/:_authToken' "${{ secrets.NPM_TOKEN }}" + + - name: Publish to npm + run: cd packages/wallet-sdk && npm publish --tag canary + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..76b780f1dc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Release + +on: + workflow_dispatch: + inputs: + packageVersion: + description: "The version to publish in MAJOR.MINOR.PATCH format" + required: true + default: "" + +jobs: + authorize: + name: Authorize + runs-on: ubuntu-latest + steps: + - name: ${{ github.actor }} permission check to update release version + uses: "lannonbr/repo-permission-check-action@2.0.2" + with: + permission: "write" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + release: + name: Release + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + pull-requests: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: 'Setup' + uses: ./.github/actions/setup + + - name: Set version + run: npm version ${{ env.PACKAGE_VERSION }} -w packages/wallet-sdk + + # Build the package + - name: Prebuild + run: yarn workspace @coinbase/wallet-sdk prebuild + + - name: Build SDK + shell: bash + run: yarn workspace @coinbase/wallet-sdk build + + # Create a pull request to update the version + - name: Create pull request + uses: peter-evans/create-pull-request@v4 + with: + title: "[Version update] v${{ env.PACKAGE_VERSION }}" + body: "Automated workflow: version update" + branch: release-v${{ env.PACKAGE_VERSION }} + delete-branch: true + commit-message: "v${{ env.PACKAGE_VERSION }}" + labels: version-update + + # Publish to npm + - name: Set deployment token + run: npm config set '//registry.npmjs.org/:_authToken' "${{ secrets.NPM_TOKEN }}" + + - name: Publish to npm + run: cd packages/wallet-sdk && npm publish --tag latest + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packages/wallet-sdk/package.json b/packages/wallet-sdk/package.json index 69970caa7c..6e44eed6cb 100644 --- a/packages/wallet-sdk/package.json +++ b/packages/wallet-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/wallet-sdk", - "version": "4.3.0", + "version": "4.3.3", "description": "Coinbase Wallet JavaScript SDK", "keywords": [ "coinbase", @@ -42,12 +42,15 @@ "@types/node": "^14.18.54", "@typescript-eslint/eslint-plugin": "^6.2.0", "@typescript-eslint/parser": "^6.2.0", + "@vitest/coverage-v8": "2.1.2", + "@vitest/web-worker": "3.2.1", "eslint": "^8.45.0", "eslint-config-preact": "^1.3.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-unused-imports": "^3.0.0", + "fake-indexeddb": "^6.0.0", "glob": "^11.0.0", "jest-websocket-mock": "^2.4.0", "jsdom": "^25.0.1", diff --git a/packages/wallet-sdk/src/core/communicator/Communicator.ts b/packages/wallet-sdk/src/core/communicator/Communicator.ts index bc7a1a1833..bf730cc886 100644 --- a/packages/wallet-sdk/src/core/communicator/Communicator.ts +++ b/packages/wallet-sdk/src/core/communicator/Communicator.ts @@ -99,7 +99,7 @@ export class Communicator { return this.popup; } - this.popup = openPopup(this.url); + this.popup = await openPopup(this.url); this.onMessage(({ event }) => event === 'PopupUnload') .then(this.disconnect) diff --git a/packages/wallet-sdk/src/core/constants.ts b/packages/wallet-sdk/src/core/constants.ts index b04e80f8bc..06c9d1ece0 100644 --- a/packages/wallet-sdk/src/core/constants.ts +++ b/packages/wallet-sdk/src/core/constants.ts @@ -1,4 +1,4 @@ export const CB_KEYS_URL = 'https://keys.coinbase.com/connect'; -export const CB_WALLET_RPC_URL = 'http://rpc.wallet.coinbase.com'; +export const CB_WALLET_RPC_URL = 'https://rpc.wallet.coinbase.com'; export const WALLETLINK_URL = 'https://www.walletlink.org'; export const CBW_MOBILE_DEEPLINK_URL = 'https://go.cb-w.com/walletlink'; diff --git a/packages/wallet-sdk/src/sign/walletlink/relay/connection/HeartbeatWorker.test.ts b/packages/wallet-sdk/src/sign/walletlink/relay/connection/HeartbeatWorker.test.ts new file mode 100644 index 0000000000..0c800fcb6e --- /dev/null +++ b/packages/wallet-sdk/src/sign/walletlink/relay/connection/HeartbeatWorker.test.ts @@ -0,0 +1,234 @@ +import '@vitest/web-worker'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('HeartbeatWorker', () => { + let worker: Worker; + + beforeEach(async () => { + // Create a new worker instance for each test + worker = new Worker(new URL('./HeartbeatWorker.ts', import.meta.url)); + }); + + afterEach(() => { + if (worker) { + worker.terminate(); + } + }); + + describe('Message Handling', () => { + it('should start heartbeat and send confirmation', async () => { + const messagePromise = new Promise((resolve) => { + worker.addEventListener('message', resolve, { once: true }); + }); + + worker.postMessage({ type: 'start' }); + + const event = await messagePromise; + expect(event.data).toEqual({ type: 'started' }); + }); + + it('should send heartbeat messages at regular intervals', async () => { + worker.postMessage({ type: 'start' }); + + await new Promise((resolve) => { + worker.addEventListener('message', (event) => { + if (event.data.type === 'started') { + resolve(); + } + }, { once: true }); + }); + + const heartbeats: MessageEvent[] = []; + const heartbeatPromise = new Promise((resolve) => { + let count = 0; + worker.addEventListener('message', (event) => { + if (event.data.type === 'heartbeat') { + heartbeats.push(event); + count++; + if (count >= 2) { + resolve(); + } + } + }); + }); + + // Wait for at least 2 heartbeat messages (this will take ~20 seconds in real time) + // For testing, we'll use a shorter timeout and verify the structure + await Promise.race([ + heartbeatPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout waiting for heartbeats')), 25000)) + ]); + + expect(heartbeats.length).toBeGreaterThanOrEqual(2); + heartbeats.forEach(event => { + expect(event.data).toEqual({ type: 'heartbeat' }); + }); + }, 30000); // 30 second timeout for this test + + it('should stop heartbeat and send confirmation', async () => { + worker.postMessage({ type: 'start' }); + + await new Promise((resolve) => { + worker.addEventListener('message', (event) => { + if (event.data.type === 'started') { + resolve(); + } + }, { once: true }); + }); + + const stopPromise = new Promise((resolve) => { + worker.addEventListener('message', (event) => { + if (event.data.type === 'stopped') { + resolve(event); + } + }, { once: true }); + }); + + worker.postMessage({ type: 'stop' }); + + const event = await stopPromise; + expect(event.data).toEqual({ type: 'stopped' }); + }); + + it('should handle unknown message types gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + worker.postMessage({ type: 'unknown' }); + + // Give the worker time to process the message + await new Promise(resolve => setTimeout(resolve, 100)); + + // Note: We can't directly verify console.warn was called in the worker context + // but we can verify the worker doesn't crash or send unexpected messages + + const messagePromise = new Promise((resolve) => { + worker.addEventListener('message', resolve, { once: true }); + }); + + worker.postMessage({ type: 'start' }); + const event = await messagePromise; + expect(event.data).toEqual({ type: 'started' }); + + consoleSpy.mockRestore(); + }); + }); + + describe('Heartbeat Interval Management', () => { + it('should handle restart without issues', async () => { + worker.postMessage({ type: 'start' }); + + await new Promise((resolve) => { + worker.addEventListener('message', (event) => { + if (event.data.type === 'started') { + resolve(); + } + }, { once: true }); + }); + + // Start again (should clear previous interval) + const secondStartPromise = new Promise((resolve) => { + worker.addEventListener('message', (event) => { + if (event.data.type === 'started') { + resolve(event); + } + }, { once: true }); + }); + + worker.postMessage({ type: 'start' }); + const event = await secondStartPromise; + expect(event.data).toEqual({ type: 'started' }); + }); + + it('should stop cleanly even when no heartbeat is running', async () => { + const stopPromise = new Promise((resolve) => { + worker.addEventListener('message', resolve, { once: true }); + }); + + // Stop without starting first + worker.postMessage({ type: 'stop' }); + + const event = await stopPromise; + expect(event.data).toEqual({ type: 'stopped' }); + }); + }); + + describe('Message Flow', () => { + it('should handle complete start-heartbeat-stop cycle', async () => { + const messages: any[] = []; + + worker.addEventListener('message', (event) => { + messages.push(event.data); + }); + + worker.postMessage({ type: 'start' }); + + await new Promise((resolve) => { + const checkMessages = () => { + if (messages.some(msg => msg.type === 'started')) { + resolve(); + } else { + setTimeout(checkMessages, 10); + } + }; + checkMessages(); + }); + + await new Promise((resolve) => { + const checkMessages = () => { + if (messages.some(msg => msg.type === 'heartbeat')) { + resolve(); + } else { + setTimeout(checkMessages, 100); + } + }; + checkMessages(); + }); + + worker.postMessage({ type: 'stop' }); + + await new Promise((resolve) => { + const checkMessages = () => { + if (messages.some(msg => msg.type === 'stopped')) { + resolve(); + } else { + setTimeout(checkMessages, 10); + } + }; + checkMessages(); + }); + + // Verify we got all expected message types + expect(messages.some(msg => msg.type === 'started')).toBe(true); + expect(messages.some(msg => msg.type === 'heartbeat')).toBe(true); + expect(messages.some(msg => msg.type === 'stopped')).toBe(true); + }, 15000); // 15 second timeout + + it('should use correct heartbeat interval timing', async () => { + const heartbeatTimes: number[] = []; + + worker.addEventListener('message', (event) => { + if (event.data.type === 'heartbeat') { + heartbeatTimes.push(Date.now()); + } + }); + + worker.postMessage({ type: 'start' }); + + await new Promise((resolve) => { + const checkHeartbeats = () => { + if (heartbeatTimes.length >= 2) { + resolve(); + } else { + setTimeout(checkHeartbeats, 100); + } + }; + checkHeartbeats(); + }); + + // Verify the interval is approximately 10 seconds (allow some tolerance) + const interval = heartbeatTimes[1] - heartbeatTimes[0]; + expect(interval).toBeGreaterThan(9500); // 9.5 seconds minimum + expect(interval).toBeLessThan(10500); // 10.5 seconds maximum + }, 25000); // 25 second timeout + }); +}); \ No newline at end of file diff --git a/packages/wallet-sdk/src/sign/walletlink/relay/connection/HeartbeatWorker.ts b/packages/wallet-sdk/src/sign/walletlink/relay/connection/HeartbeatWorker.ts new file mode 100644 index 0000000000..7d707a9ef9 --- /dev/null +++ b/packages/wallet-sdk/src/sign/walletlink/relay/connection/HeartbeatWorker.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2018-2025 Coinbase, Inc. + + +/** + * This worker is used to send heartbeat messages to the main thread. + * It is used to keep the websocket connection alive when the webpage is backgrounded. + * + */ + +const HEARTBEAT_INTERVAL = 10000; // 10 seconds + +type WorkerMessage = { + type: 'start' | 'stop'; +} + +export type WorkerResponse = { + type: 'heartbeat' | 'started' | 'stopped'; +} + +let heartbeatInterval: NodeJS.Timeout | undefined; + +// Listen for messages from the main thread +self.addEventListener('message', (event: MessageEvent) => { + const { type } = event.data; + + switch (type) { + case 'start': + startHeartbeat(); + break; + case 'stop': + stopHeartbeat(); + break; + default: + console.warn('Unknown message type received by HeartbeatWorker:', type); + } +}); + +function startHeartbeat(): void { + // Clear any existing interval + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + } + + // Start the heartbeat interval + heartbeatInterval = setInterval(() => { + // Send heartbeat message to main thread + const response: WorkerResponse = { type: 'heartbeat' }; + self.postMessage(response); + }, HEARTBEAT_INTERVAL); + + // Send confirmation that heartbeat started + const response: WorkerResponse = { type: 'started' }; + self.postMessage(response); +} + +function stopHeartbeat(): void { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = undefined; + } + + // Send confirmation that heartbeat stopped + const response: WorkerResponse = { type: 'stopped' }; + self.postMessage(response); +} + +// Handle worker termination +self.addEventListener('beforeunload', () => { + stopHeartbeat(); +}); diff --git a/packages/wallet-sdk/src/sign/walletlink/relay/connection/WalletLinkConnection.test.ts b/packages/wallet-sdk/src/sign/walletlink/relay/connection/WalletLinkConnection.test.ts index d2e06f5beb..a4c9626814 100644 --- a/packages/wallet-sdk/src/sign/walletlink/relay/connection/WalletLinkConnection.test.ts +++ b/packages/wallet-sdk/src/sign/walletlink/relay/connection/WalletLinkConnection.test.ts @@ -1,5 +1,6 @@ import { vi } from 'vitest'; +import { ScopedLocalStorage } from ':core/storage/ScopedLocalStorage.js'; import { APP_VERSION_KEY, WALLET_USER_NAME_KEY } from '../constants.js'; import { WalletLinkSession } from '../type/WalletLinkSession.js'; import { WalletLinkCipher } from './WalletLinkCipher.js'; @@ -7,7 +8,6 @@ import { WalletLinkConnection, WalletLinkConnectionUpdateListener, } from './WalletLinkConnection.js'; -import { ScopedLocalStorage } from ':core/storage/ScopedLocalStorage.js'; const decryptMock = vi.fn().mockImplementation((text) => Promise.resolve(`decrypted ${text}`)); @@ -18,10 +18,18 @@ describe('WalletLinkConnection', () => { let connection: WalletLinkConnection; let listener: WalletLinkConnectionUpdateListener; + let mockWorker: any; beforeEach(() => { vi.clearAllMocks(); + mockWorker = { + postMessage: vi.fn(), + terminate: vi.fn(), + addEventListener: vi.fn(), + }; + global.Worker = vi.fn().mockImplementation(() => mockWorker); + connection = new WalletLinkConnection({ session, linkAPIUrl: 'http://link-api-url', @@ -142,4 +150,77 @@ describe('WalletLinkConnection', () => { }); }); }); + + describe('Heartbeat Worker Management', () => { + it('should create a heartbeat worker when startHeartbeat is called', () => { + (connection as any).startHeartbeat(); + + expect(global.Worker).toHaveBeenCalledWith(expect.any(URL), { type: 'module' }); + + expect(mockWorker.postMessage).toHaveBeenCalledWith({ type: 'start' }); + }); + + it('should stop heartbeat worker when stopHeartbeat is called', () => { + (connection as any).startHeartbeat(); + + vi.clearAllMocks(); + + (connection as any).stopHeartbeat(); + + expect(mockWorker.postMessage).toHaveBeenCalledWith({ type: 'stop' }); + expect(mockWorker.terminate).toHaveBeenCalled(); + }); + + it('should terminate existing worker before creating new one', () => { + (connection as any).startHeartbeat(); + const firstWorker = mockWorker; + + const secondWorker = { + postMessage: vi.fn(), + terminate: vi.fn(), + addEventListener: vi.fn(), + }; + global.Worker = vi.fn().mockImplementation(() => secondWorker); + + (connection as any).startHeartbeat(); + + // First worker should be terminated + expect(firstWorker.terminate).toHaveBeenCalled(); + + // New worker should be created and started + expect(secondWorker.postMessage).toHaveBeenCalledWith({ type: 'start' }); + }); + + it('should handle heartbeat messages from worker', () => { + const heartbeatSpy = vi.spyOn(connection as any, 'heartbeat').mockImplementation(() => {}); + + (connection as any).startHeartbeat(); + + const messageListener = mockWorker.addEventListener.mock.calls.find( + (call: any[]) => call[0] === 'message' + )?.[1]; + + expect(messageListener).toBeDefined(); + + messageListener({ data: { type: 'heartbeat' } }); + + expect(heartbeatSpy).toHaveBeenCalled(); + }); + + it('should handle stop when no worker exists', () => { + expect(() => { + (connection as any).stopHeartbeat(); + }).not.toThrow(); + + expect(mockWorker.postMessage).not.toHaveBeenCalled(); + expect(mockWorker.terminate).not.toHaveBeenCalled(); + }); + + it('should setup worker listeners correctly', () => { + (connection as any).startHeartbeat(); + + expect(mockWorker.addEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + expect(mockWorker.addEventListener).toHaveBeenCalledWith('error', expect.any(Function)); + }); + }); }); diff --git a/packages/wallet-sdk/src/sign/walletlink/relay/connection/WalletLinkConnection.ts b/packages/wallet-sdk/src/sign/walletlink/relay/connection/WalletLinkConnection.ts index c3c1b6f193..f2522a6ebb 100644 --- a/packages/wallet-sdk/src/sign/walletlink/relay/connection/WalletLinkConnection.ts +++ b/packages/wallet-sdk/src/sign/walletlink/relay/connection/WalletLinkConnection.ts @@ -1,15 +1,16 @@ // Copyright (c) 2018-2023 Coinbase, Inc. +import { IntNumber } from ':core/type/index.js'; import { APP_VERSION_KEY, WALLET_USER_NAME_KEY } from '../constants.js'; import { ClientMessage } from '../type/ClientMessage.js'; import { ServerMessage, ServerMessageType } from '../type/ServerMessage.js'; import { WalletLinkEventData } from '../type/WalletLinkEventData.js'; import { WalletLinkSession } from '../type/WalletLinkSession.js'; import { Web3Response } from '../type/Web3Response.js'; +import { WorkerResponse } from './HeartbeatWorker.js'; import { WalletLinkCipher } from './WalletLinkCipher.js'; import { WalletLinkHTTP } from './WalletLinkHTTP.js'; import { ConnectionState, WalletLinkWebSocket } from './WalletLinkWebSocket.js'; -import { IntNumber } from ':core/type/index.js'; const HEARTBEAT_INTERVAL = 10000; const REQUEST_TIMEOUT = 60000; @@ -43,6 +44,7 @@ export class WalletLinkConnection { private cipher: WalletLinkCipher; private ws: WalletLinkWebSocket; private http: WalletLinkHTTP; + private heartbeatWorker?: Worker; /** * Constructor @@ -62,6 +64,9 @@ export class WalletLinkConnection { let connected = false; switch (state) { case ConnectionState.DISCONNECTED: + // Stop heartbeat when disconnected + this.stopHeartbeat(); + // if DISCONNECTED and not destroyed if (!this.destroyed) { const connect = async () => { @@ -85,13 +90,9 @@ export class WalletLinkConnection { connected = await this.handleConnected(); // send heartbeat every n seconds while connected - // if CONNECTED, start the heartbeat timer - // first timer event updates lastHeartbeat timestamp - // subsequent calls send heartbeat message + // if CONNECTED, start the heartbeat timer using WebWorker this.updateLastHeartbeat(); - setInterval(() => { - this.heartbeat(); - }, HEARTBEAT_INTERVAL); + this.startHeartbeat(); // check for unseen events if (this.shouldFetchUnseenEventsOnConnect) { @@ -174,6 +175,7 @@ export class WalletLinkConnection { ); this.destroyed = true; + this.stopHeartbeat(); this.ws.disconnect(); this.listener = undefined; } @@ -307,6 +309,53 @@ export class WalletLinkConnection { this.lastHeartbeatResponse = Date.now(); } + private startHeartbeat(): void { + if (this.heartbeatWorker) { + this.heartbeatWorker.terminate(); + } + + try { + // We put the heartbeat interval on a worker to avoid dropping the websocket connection when the webpage is backgrounded. + const workerUrl = new URL('./HeartbeatWorker.js', import.meta.url); + this.heartbeatWorker = new Worker(workerUrl, { type: 'module' }); + this.setupWorkerListeners(); + + this.heartbeatWorker.postMessage({ type: 'start' }); + } catch (error) { + console.warn('Failed to create external heartbeat worker', error); + } + } + + private setupWorkerListeners(): void { + if (!this.heartbeatWorker) return; + + this.heartbeatWorker.addEventListener('message', (event: MessageEvent) => { + const { type } = event.data; + + switch (type) { + case 'heartbeat': + this.heartbeat(); + break; + case 'started': + case 'stopped': + // noop + break; + } + }); + + this.heartbeatWorker.addEventListener('error', (error) => { + console.error('Heartbeat worker error:', error); + }); + } + + private stopHeartbeat(): void { + if (this.heartbeatWorker) { + this.heartbeatWorker.postMessage({ type: 'stop' }); + this.heartbeatWorker.terminate(); + this.heartbeatWorker = undefined; + } + } + private heartbeat(): void { if (Date.now() - this.lastHeartbeatResponse > HEARTBEAT_INTERVAL * 2) { this.ws.disconnect(); diff --git a/packages/wallet-sdk/src/sign/walletlink/relay/ui/WalletLinkRelayUI.ts b/packages/wallet-sdk/src/sign/walletlink/relay/ui/WalletLinkRelayUI.ts index 600ca29d88..6302b961c0 100644 --- a/packages/wallet-sdk/src/sign/walletlink/relay/ui/WalletLinkRelayUI.ts +++ b/packages/wallet-sdk/src/sign/walletlink/relay/ui/WalletLinkRelayUI.ts @@ -2,6 +2,9 @@ import { injectCssReset } from './components/cssReset/cssReset.js'; import { Snackbar, SnackbarInstanceProps } from './components/Snackbar/Snackbar.js'; import { RelayUI } from './RelayUI.js'; +export const RETRY_SVG_PATH = + 'M5.00008 0.96875C6.73133 0.96875 8.23758 1.94375 9.00008 3.375L10.0001 2.375V5.5H9.53133H7.96883H6.87508L7.80633 4.56875C7.41258 3.3875 6.31258 2.53125 5.00008 2.53125C3.76258 2.53125 2.70633 3.2875 2.25633 4.36875L0.812576 3.76875C1.50008 2.125 3.11258 0.96875 5.00008 0.96875ZM2.19375 6.43125C2.5875 7.6125 3.6875 8.46875 5 8.46875C6.2375 8.46875 7.29375 7.7125 7.74375 6.63125L9.1875 7.23125C8.5 8.875 6.8875 10.0312 5 10.0312C3.26875 10.0312 1.7625 9.05625 1 7.625L0 8.625V5.5H0.46875H2.03125H3.125L2.19375 6.43125Z'; + export class WalletLinkRelayUI implements RelayUI { private readonly snackbar: Snackbar; private attached = false; @@ -67,7 +70,7 @@ export class WalletLinkRelayUI implements RelayUI { info: 'Reset connection', svgWidth: '10', svgHeight: '11', - path: 'M5.00008 0.96875C6.73133 0.96875 8.23758 1.94375 9.00008 3.375L10.0001 2.375V5.5H9.53133H7.96883H6.87508L7.80633 4.56875C7.41258 3.3875 6.31258 2.53125 5.00008 2.53125C3.76258 2.53125 2.70633 3.2875 2.25633 4.36875L0.812576 3.76875C1.50008 2.125 3.11258 0.96875 5.00008 0.96875ZM2.19375 6.43125C2.5875 7.6125 3.6875 8.46875 5 8.46875C6.2375 8.46875 7.29375 7.7125 7.74375 6.63125L9.1875 7.23125C8.5 8.875 6.8875 10.0312 5 10.0312C3.26875 10.0312 1.7625 9.05625 1 7.625L0 8.625V5.5H0.46875H2.03125H3.125L2.19375 6.43125Z', + path: RETRY_SVG_PATH, defaultFillRule: 'evenodd', defaultClipRule: 'evenodd', onClick: options.onResetConnection, diff --git a/packages/wallet-sdk/src/util/web.test.ts b/packages/wallet-sdk/src/util/web.test.ts index 3b6ef5dc3a..323f7aa092 100644 --- a/packages/wallet-sdk/src/util/web.test.ts +++ b/packages/wallet-sdk/src/util/web.test.ts @@ -1,13 +1,27 @@ +import { waitFor } from '@testing-library/preact'; import { Mock, vi } from 'vitest'; import { NAME, VERSION } from '../sdk-info.js'; import { getCrossOriginOpenerPolicy } from './checkCrossOriginOpenerPolicy.js'; import { closePopup, openPopup } from './web.js'; -import { standardErrors } from ':core/error/errors.js'; vi.mock('./checkCrossOriginOpenerPolicy'); (getCrossOriginOpenerPolicy as Mock).mockReturnValue('null'); +// Mock Snackbar class +const mockPresentItem = vi.fn().mockReturnValue(() => {}); +const mockClear = vi.fn(); +const mockAttach = vi.fn(); +const mockInstance = { + presentItem: mockPresentItem, + clear: mockClear, + attach: mockAttach, +}; + +vi.mock(':sign/walletlink/relay/ui/components/Snackbar/Snackbar.js', () => ({ + Snackbar: vi.fn().mockImplementation(() => mockInstance), +})); + const mockOrigin = 'http://localhost'; describe('PopupManager', () => { @@ -28,11 +42,11 @@ describe('PopupManager', () => { vi.clearAllMocks(); }); - it('should open a popup with correct settings and focus it', () => { + it('should open a popup with correct settings and focus it', async () => { const url = new URL('https://example.com'); (window.open as Mock).mockReturnValue({ focus: vi.fn() }); - const popup = openPopup(url); + const popup = await openPopup(url); expect(window.open).toHaveBeenNthCalledWith( 1, @@ -48,12 +62,51 @@ describe('PopupManager', () => { expect(url.searchParams.get('coop')).toBe('null'); }); - it('should throw an error if popup fails to open', () => { + it('should show snackbar with retry button when popup is blocked and retry successfully', async () => { + const url = new URL('https://example.com'); + const mockPopup = { focus: vi.fn() }; + (window.open as Mock).mockReturnValueOnce(null).mockReturnValueOnce(mockPopup); + + const promise = openPopup(url); + + await waitFor(() => { + expect(mockPresentItem).toHaveBeenCalledWith( + expect.objectContaining({ + autoExpand: true, + message: 'Popup was blocked. Try again.', + }) + ); + }); + + const retryButton = mockPresentItem.mock.calls[0][0].menuItems[0]; + retryButton.onClick(); + + const popup = await promise; + expect(popup).toBe(mockPopup); + expect(mockClear).toHaveBeenCalled(); + expect(window.open).toHaveBeenCalledTimes(2); + }); + + it('should show snackbar with retry button when popup is blocked and reject if retry fails', async () => { + const url = new URL('https://example.com'); (window.open as Mock).mockReturnValue(null); - expect(() => openPopup(new URL('https://example.com'))).toThrow( - standardErrors.rpc.internal('Pop up window failed to open') - ); + const promise = openPopup(url); + + await waitFor(() => { + expect(mockPresentItem).toHaveBeenCalledWith( + expect.objectContaining({ + autoExpand: true, + message: 'Popup was blocked. Try again.', + }) + ); + }); + + const retryButton = mockPresentItem.mock.calls[0][0].menuItems[0]; + retryButton.onClick(); + + await expect(promise).rejects.toThrow('Popup window was blocked'); + expect(mockClear).toHaveBeenCalled(); }); it('should close an open popup window', () => { diff --git a/packages/wallet-sdk/src/util/web.ts b/packages/wallet-sdk/src/util/web.ts index c02500d2f2..584d4acbe0 100644 --- a/packages/wallet-sdk/src/util/web.ts +++ b/packages/wallet-sdk/src/util/web.ts @@ -1,31 +1,76 @@ import { NAME, VERSION } from '../sdk-info.js'; import { getCrossOriginOpenerPolicy } from './checkCrossOriginOpenerPolicy.js'; import { standardErrors } from ':core/error/errors.js'; +import { Snackbar } from ':sign/walletlink/relay/ui/components/Snackbar/Snackbar.js'; +import { RETRY_SVG_PATH } from ':sign/walletlink/relay/ui/WalletLinkRelayUI.js'; const POPUP_WIDTH = 420; const POPUP_HEIGHT = 540; -// Window Management +const RETRY_BUTTON = { + isRed: false, + info: 'Retry', + svgWidth: '10', + svgHeight: '11', + path: RETRY_SVG_PATH, + defaultFillRule: 'evenodd', + defaultClipRule: 'evenodd', +} as const; -export function openPopup(url: URL): Window { +const POPUP_BLOCKED_MESSAGE = 'Popup was blocked. Try again.'; + +let snackbar: Snackbar | null = null; + +export function openPopup(url: URL): Promise { const left = (window.innerWidth - POPUP_WIDTH) / 2 + window.screenX; const top = (window.innerHeight - POPUP_HEIGHT) / 2 + window.screenY; appendAppInfoQueryParams(url); - const popupId = `wallet_${crypto.randomUUID()}`; - const popup = window.open( - url, - popupId, - `width=${POPUP_WIDTH}, height=${POPUP_HEIGHT}, left=${left}, top=${top}` - ); + function tryOpenPopup(): Window | null { + const popupId = `wallet_${crypto.randomUUID()}`; + const popup = window.open( + url, + popupId, + `width=${POPUP_WIDTH}, height=${POPUP_HEIGHT}, left=${left}, top=${top}` + ); + + popup?.focus(); - popup?.focus(); + if (!popup) { + return null; + } + + return popup; + } + let popup = tryOpenPopup(); + + // If the popup was blocked, show a snackbar with a retry button if (!popup) { - throw standardErrors.rpc.internal('Pop up window failed to open'); + const sb = initSnackbar(); + return new Promise((resolve, reject) => { + sb.presentItem({ + autoExpand: true, + message: POPUP_BLOCKED_MESSAGE, + menuItems: [ + { + ...RETRY_BUTTON, + onClick: () => { + popup = tryOpenPopup(); + if (popup) { + resolve(popup); + } else { + reject(standardErrors.rpc.internal('Popup window was blocked')); + } + sb.clear(); + }, + }, + ], + }); + }); } - return popup; + return Promise.resolve(popup); } export function closePopup(popup: Window | null) { @@ -46,3 +91,14 @@ function appendAppInfoQueryParams(url: URL) { url.searchParams.append(key, value.toString()); } } + +function initSnackbar() { + if (!snackbar) { + const root = document.createElement('div'); + root.className = '-cbwsdk-css-reset'; + document.body.appendChild(root); + snackbar = new Snackbar(); + snackbar.attach(root); + } + return snackbar; +} diff --git a/packages/wallet-sdk/tsconfig.base.json b/packages/wallet-sdk/tsconfig.base.json index 2315bc3b82..737ce37fb8 100644 --- a/packages/wallet-sdk/tsconfig.base.json +++ b/packages/wallet-sdk/tsconfig.base.json @@ -13,7 +13,8 @@ "outDir": "./dist", "paths": { ":util/*": ["src/util/*"], - ":core/*": ["src/core/*"] + ":core/*": ["src/core/*"], + ":sign/*": ["src/sign/*"] }, "target": "es2017", "jsx": "react", diff --git a/yarn.lock b/yarn.lock index 4e97924e80..1b5d539188 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,7 +19,7 @@ __metadata: languageName: node linkType: hard -"@ampproject/remapping@npm:^2.2.0": +"@ampproject/remapping@npm:^2.2.0, @ampproject/remapping@npm:^2.3.0": version: 2.3.0 resolution: "@ampproject/remapping@npm:2.3.0" dependencies: @@ -156,6 +156,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-string-parser@npm:7.27.1" + checksum: 0a8464adc4b39b138aedcb443b09f4005d86207d7126e5e079177e05c3116107d856ec08282b365e9a79a9872f40f4092a6127f8d74c8a01c1ef789dacfc25d6 + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.24.7": version: 7.24.7 resolution: "@babel/helper-validator-identifier@npm:7.24.7" @@ -163,6 +170,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-validator-identifier@npm:7.27.1" + checksum: 3c7e8391e59d6c85baeefe9afb86432f2ab821c6232b00ea9082a51d3e7e95a2f3fb083d74dc1f49ac82cf238e1d2295dafcb001f7b0fab479f3f56af5eaaa47 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.24.8": version: 7.24.8 resolution: "@babel/helper-validator-option@npm:7.24.8" @@ -203,6 +217,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.25.4": + version: 7.27.5 + resolution: "@babel/parser@npm:7.27.5" + dependencies: + "@babel/types": ^7.27.3 + bin: + parser: ./bin/babel-parser.js + checksum: 16f00a12895522c1682f1f047332010e129ba517add3a2db347a658e02f60434fc38f9105a9d6ec3fd6bfb5d1b0b70d88585c1f10e06e2b58fba29004a42d648 + languageName: node + linkType: hard + "@babel/plugin-syntax-class-properties@npm:^7.12.13": version: 7.12.13 resolution: "@babel/plugin-syntax-class-properties@npm:7.12.13" @@ -271,6 +296,23 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.25.4, @babel/types@npm:^7.27.3": + version: 7.27.3 + resolution: "@babel/types@npm:7.27.3" + dependencies: + "@babel/helper-string-parser": ^7.27.1 + "@babel/helper-validator-identifier": ^7.27.1 + checksum: f0d43c0231f3ebc118480e149292dcd92ea128e2650285ced99ff2e5610db2171305f59aa07406ba0cb36af8e4331a53a69576d6b0c3f3176144dd3ad514b9ae + languageName: node + linkType: hard + +"@bcoe/v8-coverage@npm:^0.2.3": + version: 0.2.3 + resolution: "@bcoe/v8-coverage@npm:0.2.3" + checksum: 850f9305536d0f2bd13e9e0881cb5f02e4f93fad1189f7b2d4bebf694e3206924eadee1068130d43c11b750efcc9405f88a8e42ef098b6d75239c0f047de1a27 + languageName: node + linkType: hard + "@chakra-ui/accordion@npm:2.3.1": version: 2.3.1 resolution: "@chakra-ui/accordion@npm:2.3.1" @@ -1540,6 +1582,8 @@ __metadata: "@types/node": ^14.18.54 "@typescript-eslint/eslint-plugin": ^6.2.0 "@typescript-eslint/parser": ^6.2.0 + "@vitest/coverage-v8": 2.1.2 + "@vitest/web-worker": 3.2.1 clsx: ^1.2.1 eslint: ^8.45.0 eslint-config-preact: ^1.3.0 @@ -1548,6 +1592,7 @@ __metadata: eslint-plugin-simple-import-sort: ^10.0.0 eslint-plugin-unused-imports: ^3.0.0 eventemitter3: ^5.0.1 + fake-indexeddb: ^6.0.0 glob: ^11.0.0 jest-websocket-mock: ^2.4.0 jsdom: ^25.0.1 @@ -2009,6 +2054,13 @@ __metadata: languageName: node linkType: hard +"@istanbuljs/schema@npm:^0.1.2": + version: 0.1.3 + resolution: "@istanbuljs/schema@npm:0.1.3" + checksum: 5282759d961d61350f33d9118d16bcaed914ebf8061a52f4fa474b2cb08720c9c81d165e13b82f2e5a8a212cc5af482f0c6fc1ac27b9e067e5394c9a6ed186c9 + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -2060,7 +2112,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.20, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": +"@jridgewell/trace-mapping@npm:^0.3.20, @jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": version: 0.3.25 resolution: "@jridgewell/trace-mapping@npm:0.3.25" dependencies: @@ -3110,6 +3162,32 @@ __metadata: languageName: node linkType: hard +"@vitest/coverage-v8@npm:2.1.2": + version: 2.1.2 + resolution: "@vitest/coverage-v8@npm:2.1.2" + dependencies: + "@ampproject/remapping": ^2.3.0 + "@bcoe/v8-coverage": ^0.2.3 + debug: ^4.3.6 + istanbul-lib-coverage: ^3.2.2 + istanbul-lib-report: ^3.0.1 + istanbul-lib-source-maps: ^5.0.6 + istanbul-reports: ^3.1.7 + magic-string: ^0.30.11 + magicast: ^0.3.4 + std-env: ^3.7.0 + test-exclude: ^7.0.1 + tinyrainbow: ^1.2.0 + peerDependencies: + "@vitest/browser": 2.1.2 + vitest: 2.1.2 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 510efabda04f765f427ff6d2df528cc3cd1534ec8c6f5cfc2c403e23e9969f148979d8363109aef1ba0dd0724d19e0e2140b893caa86218345872852cc51d591 + languageName: node + linkType: hard + "@vitest/expect@npm:2.1.2": version: 2.1.2 resolution: "@vitest/expect@npm:2.1.2" @@ -3192,6 +3270,17 @@ __metadata: languageName: node linkType: hard +"@vitest/web-worker@npm:3.2.1": + version: 3.2.1 + resolution: "@vitest/web-worker@npm:3.2.1" + dependencies: + debug: "npm:^4.4.1" + peerDependencies: + vitest: 3.2.1 + checksum: c10731380041978a6a49aeb2866856c4067d178eb664b94ea96c9334f2d8ae54195d9fd8be0bc8f03bb3a3b9806b290cff34a775f731ecd23826cfc036217bff + languageName: node + linkType: hard + "@webassemblyjs/ast@npm:1.12.1, @webassemblyjs/ast@npm:^1.12.1": version: 1.12.1 resolution: "@webassemblyjs/ast@npm:1.12.1" @@ -4440,6 +4529,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.4.1": + version: 4.4.1 + resolution: "debug@npm:4.4.1" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: a43826a01cda685ee4cec00fb2d3322eaa90ccadbef60d9287debc2a886be3e835d9199c80070ede75a409ee57828c4c6cd80e4b154f2843f0dc95a570dc0729 + languageName: node + linkType: hard + "decimal.js@npm:^10.4.3": version: 10.4.3 resolution: "decimal.js@npm:10.4.3" @@ -5426,6 +5527,13 @@ __metadata: languageName: node linkType: hard +"fake-indexeddb@npm:^6.0.0": + version: 6.0.1 + resolution: "fake-indexeddb@npm:6.0.1" + checksum: c4b8a0576cf3165238494b67641539d4ff36194e038b36e6992449eb882923dfaadba78a62cfc7d5ae9a5c0ac2fa1e70af5cb6c2228dc764ac79b65f0e68e942 + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -5835,7 +5943,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10": +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.4.1": version: 10.4.5 resolution: "glob@npm:10.4.5" dependencies: @@ -6024,6 +6132,13 @@ __metadata: languageName: node linkType: hard +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: d2df2da3ad40ca9ee3a39c5cc6475ef67c8f83c234475f24d8e9ce0dc80a2c82df8e1d6fa78ddd1e9022a586ea1bd247a615e80a5cd9273d90111ddda7d9e974 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -6476,6 +6591,45 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 2367407a8d13982d8f7a859a35e7f8dd5d8f75aae4bb5484ede3a9ea1b426dc245aff28b976a2af48ee759fdd9be374ce2bd2669b644f31e76c5f46a2e29a831 + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: ^3.0.0 + make-dir: ^4.0.0 + supports-color: ^7.1.0 + checksum: fd17a1b879e7faf9bb1dc8f80b2a16e9f5b7b8498fe6ed580a618c34df0bfe53d2abd35bf8a0a00e628fb7405462576427c7df20bbe4148d19c14b431c974b21 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^5.0.6": + version: 5.0.6 + resolution: "istanbul-lib-source-maps@npm:5.0.6" + dependencies: + "@jridgewell/trace-mapping": ^0.3.23 + debug: ^4.1.1 + istanbul-lib-coverage: ^3.0.0 + checksum: 8dd6f2c1e2ecaacabeef8dc9ab52c4ed0a6036310002cf7f46ea6f3a5fb041da8076f5350e6a6be4c60cd4f231c51c73e042044afaf44820d857d92ecfb8ab6c + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.7": + version: 3.1.7 + resolution: "istanbul-reports@npm:3.1.7" + dependencies: + html-escaper: ^2.0.0 + istanbul-lib-report: ^3.0.0 + checksum: 2072db6e07bfbb4d0eb30e2700250636182398c1af811aea5032acb219d2080f7586923c09fa194029efd6b92361afb3dcbe1ebcc3ee6651d13340f7c6c4ed95 + languageName: node + linkType: hard + "iterator.prototype@npm:^1.1.2": version: 1.1.2 resolution: "iterator.prototype@npm:1.1.2" @@ -6913,6 +7067,26 @@ __metadata: languageName: node linkType: hard +"magicast@npm:^0.3.4": + version: 0.3.5 + resolution: "magicast@npm:0.3.5" + dependencies: + "@babel/parser": ^7.25.4 + "@babel/types": ^7.25.4 + source-map-js: ^1.2.0 + checksum: 668f07ade907a44bccfc9a9321588473f6d5fa25329aa26b9ad9a3bf87cc2e6f9c482cbdd3e33c0b9ab9b79c065630c599cc055a12f881c8c924ee0d7282cdce + languageName: node + linkType: hard + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: ^7.5.3 + checksum: bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a + languageName: node + linkType: hard + "make-fetch-happen@npm:^13.0.0": version: 13.0.1 resolution: "make-fetch-happen@npm:13.0.1" @@ -8627,7 +8801,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.1": +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 4eb0cd997cdf228bc253bcaff9340afeb706176e64868ecd20efbe6efea931465f43955612346d6b7318789e5265bdc419bc7669c1cebe3db0eb255f57efa76b @@ -9048,6 +9222,17 @@ __metadata: languageName: node linkType: hard +"test-exclude@npm:^7.0.1": + version: 7.0.1 + resolution: "test-exclude@npm:7.0.1" + dependencies: + "@istanbuljs/schema": ^0.1.2 + glob: ^10.4.1 + minimatch: ^9.0.4 + checksum: e5a49a054bf2da74467dd8149b202166e36275c0dc2c9585f7d34de99c6d055d2287ac8d2a8e4c27c59b893acbc671af3fa869e8069a58ad117250e9c01c726b + languageName: node + linkType: hard + "text-decoder@npm:^1.1.0": version: 1.2.0 resolution: "text-decoder@npm:1.2.0"