diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..6cf9ae5a --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# style: use semicolons, trailing comma with prettier #539 +ec7b4ec18bad5f1a4d8b0529df0658fa4a203da9 diff --git a/.github/actions/bump-manifest-version.cjs b/.github/actions/bump-manifest-version.cjs index 89ec7cd8..c7f001c2 100644 --- a/.github/actions/bump-manifest-version.cjs +++ b/.github/actions/bump-manifest-version.cjs @@ -1,28 +1,28 @@ // @ts-check /* eslint-disable @typescript-eslint/no-require-imports, no-console */ -const fs = require('node:fs/promises') +const fs = require('node:fs/promises'); /** @param {import('github-script').AsyncFunctionArguments} AsyncFunctionArguments */ module.exports = async ({ core }) => { - const manifestPath = './src/manifest.json' - const manifestFile = await fs.readFile(manifestPath, 'utf8') - const manifest = JSON.parse(manifestFile) + const manifestPath = './src/manifest.json'; + const manifestFile = await fs.readFile(manifestPath, 'utf8'); + const manifest = JSON.parse(manifestFile); /**@type {string} */ - const existingVersion = manifest.version + const existingVersion = manifest.version; - const bumpType = /** @type {BumpType} */ (process.env.INPUT_VERSION) + const bumpType = /** @type {BumpType} */ (process.env.INPUT_VERSION); if (!bumpType) { - throw new Error('Missing bump type') + throw new Error('Missing bump type'); } - const version = bumpVersion(existingVersion, bumpType).join('.') + const version = bumpVersion(existingVersion, bumpType).join('.'); - console.log({ existingVersion, bumpType, version }) + console.log({ existingVersion, bumpType, version }); - manifest.version = version - await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)) - core.setOutput('version', version) -} + manifest.version = version; + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + core.setOutput('version', version); +}; /** * @typedef {'build' | 'patch' | 'minor'} BumpType @@ -31,20 +31,20 @@ module.exports = async ({ core }) => { * @return {[major: number, minor: number, patch: number, build: number]} */ function bumpVersion(existingVersion, type) { - const parts = existingVersion.split('.').map(Number) + const parts = existingVersion.split('.').map(Number); if (parts.length !== 4 || parts.some((e) => !Number.isSafeInteger(e))) { - throw new Error('Existing version does not have right format') + throw new Error('Existing version does not have right format'); } - const [major, minor, patch, build] = parts + const [major, minor, patch, build] = parts; switch (type) { case 'build': - return [major, minor, patch, build + 1] + return [major, minor, patch, build + 1]; case 'patch': - return [major, minor, patch + 1, 0] + return [major, minor, patch + 1, 0]; case 'minor': - return [major, minor + 1, 0, 0] + return [major, minor + 1, 0, 0]; default: - throw new Error('Unknown bump type: ' + type) + throw new Error('Unknown bump type: ' + type); } } diff --git a/.github/actions/constants.cjs b/.github/actions/constants.cjs index 0c499cc7..3ba55382 100644 --- a/.github/actions/constants.cjs +++ b/.github/actions/constants.cjs @@ -5,25 +5,25 @@ */ const BADGE = - 'Badge' + 'Badge'; /** @type {Browser[]} */ -const BROWSERS = ['chrome', 'firefox'] +const BROWSERS = ['chrome', 'firefox']; const COLORS = { green: '3fb950', - red: 'd73a49' -} + red: 'd73a49', +}; const TEMPLATE_VARS = { tableBody: '{{ TABLE_BODY }}', sha: '{{ SHA }}', conclusion: '{{ CONCLUSION }}', badgeColor: '{{ BADGE_COLOR }}', badgeLabel: '{{ BADGE_LABEL }}', - jobLogs: '{{ JOB_LOGS }}' -} + jobLogs: '{{ JOB_LOGS }}', +}; module.exports = { BADGE, BROWSERS, COLORS, - TEMPLATE_VARS -} + TEMPLATE_VARS, +}; diff --git a/.github/actions/delete-artifacts.cjs b/.github/actions/delete-artifacts.cjs index 7cdf1fe9..2aa9a3d4 100644 --- a/.github/actions/delete-artifacts.cjs +++ b/.github/actions/delete-artifacts.cjs @@ -1,6 +1,6 @@ // @ts-check /* eslint-disable @typescript-eslint/no-require-imports, no-console */ -const { BROWSERS } = require('./constants.cjs') +const { BROWSERS } = require('./constants.cjs'); /** * @param {Pick} AsyncFunctionArguments @@ -10,9 +10,9 @@ async function getBrowserArtifacts({ github, context }, name) { const result = await github.rest.actions.listArtifactsForRepo({ owner: context.repo.owner, repo: context.repo.repo, - name - }) - return result.data.artifacts + name, + }); + return result.data.artifacts; } /** @@ -22,40 +22,40 @@ async function getBrowserArtifacts({ github, context }, name) { async function getPRArtifacts({ github, context }, prNumber) { const data = await Promise.all( BROWSERS.map((browser) => - getBrowserArtifacts({ github, context }, `${prNumber}-${browser}`) - ) - ) + getBrowserArtifacts({ github, context }, `${prNumber}-${browser}`), + ), + ); /** @type {{id: number}[]} */ - const artifacts = [] + const artifacts = []; for (let i = 0; i < data.length; i++) { // same as `artifacts.push(...data[i])` but it's a bit faster - artifacts.push.apply(artifacts, data[i]) + artifacts.push.apply(artifacts, data[i]); } - return artifacts + return artifacts; } /** @param {import('github-script').AsyncFunctionArguments} AsyncFunctionArguments */ module.exports = async ({ github, context, core }) => { if (context.payload.action !== 'closed') { - core.setFailed('This action only works on closed PRs.') + core.setFailed('This action only works on closed PRs.'); } - const { owner, repo } = context.repo + const { owner, repo } = context.repo; /** @type {number} */ - const prNumber = context.payload.number + const prNumber = context.payload.number; - const artifacts = await getPRArtifacts({ github, context }, prNumber) + const artifacts = await getPRArtifacts({ github, context }, prNumber); await Promise.all( artifacts.map((artifact) => github.rest.actions.deleteArtifact({ owner, repo, - artifact_id: artifact.id - }) - ) - ) + artifact_id: artifact.id, + }), + ), + ); - console.log(`Deleted ${artifacts.length} artifacts for PR #${prNumber}.`) -} + console.log(`Deleted ${artifacts.length} artifacts for PR #${prNumber}.`); +}; diff --git a/.github/actions/get-built-version.cjs b/.github/actions/get-built-version.cjs index 4bc30d3f..56a24200 100644 --- a/.github/actions/get-built-version.cjs +++ b/.github/actions/get-built-version.cjs @@ -1,6 +1,6 @@ // @ts-check /* eslint-disable @typescript-eslint/no-require-imports */ -const fs = require('node:fs/promises') +const fs = require('node:fs/promises'); /** * Retrieves the manifest version from the built extension. @@ -9,7 +9,7 @@ const fs = require('node:fs/promises') module.exports = async ({ core }) => { const manifest = await fs .readFile('./dist/chrome/manifest.json', 'utf8') - .then(JSON.parse) + .then(JSON.parse); - core.setOutput('version', manifest.version) -} + core.setOutput('version', manifest.version); +}; diff --git a/.github/actions/get-workflow-artifacts.cjs b/.github/actions/get-workflow-artifacts.cjs index cd89b196..d3fb8aa2 100644 --- a/.github/actions/get-workflow-artifacts.cjs +++ b/.github/actions/get-workflow-artifacts.cjs @@ -1,7 +1,7 @@ // @ts-check /* eslint-disable @typescript-eslint/no-require-imports, no-console */ -const fs = require('node:fs/promises') -const { COLORS, TEMPLATE_VARS, BADGE } = require('./constants.cjs') +const fs = require('node:fs/promises'); +const { COLORS, TEMPLATE_VARS, BADGE } = require('./constants.cjs'); /** * @typedef {import('./constants.cjs').Browser} Browser @@ -12,14 +12,14 @@ const ARTIFACTS_DATA = { chrome: { name: 'Chrome', url: '', - size: '' + size: '', }, firefox: { name: 'Firefox', url: '', - size: '' - } -} + size: '', + }, +}; /** * @param {string} conclusion @@ -29,7 +29,7 @@ const ARTIFACTS_DATA = { function getBadge(conclusion, badgeColor, badgeLabel) { return BADGE.replace(TEMPLATE_VARS.conclusion, conclusion) .replace(TEMPLATE_VARS.badgeColor, badgeColor) - .replace(TEMPLATE_VARS.badgeLabel, badgeLabel) + .replace(TEMPLATE_VARS.badgeLabel, badgeLabel); } /** @@ -37,73 +37,73 @@ function getBadge(conclusion, badgeColor, badgeLabel) { * @param {number} decimals */ function formatBytes(bytes, decimals = 2) { - if (!Number(bytes)) return '0B' - const k = 1024 - const dm = decimals < 0 ? 0 : decimals - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))}${sizes[i]}` + if (!Number(bytes)) return '0B'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))}${sizes[i]}`; } /** @param {import('github-script').AsyncFunctionArguments} AsyncFunctionArguments */ module.exports = async ({ github, context, core }) => { - const { owner, repo } = context.repo - const baseUrl = context.payload.repository?.html_url - const suiteId = context.payload.workflow_run.check_suite_id - const runId = context.payload.workflow_run.id - const conclusion = context.payload.workflow_run.conclusion - const sha = context.payload.workflow_run.pull_requests[0].head.sha - const prNumber = context.payload.workflow_run.pull_requests[0].number - const jobLogsUrl = `${baseUrl}/actions/runs/${context.payload.workflow_run.id}` + const { owner, repo } = context.repo; + const baseUrl = context.payload.repository?.html_url; + const suiteId = context.payload.workflow_run.check_suite_id; + const runId = context.payload.workflow_run.id; + const conclusion = context.payload.workflow_run.conclusion; + const sha = context.payload.workflow_run.pull_requests[0].head.sha; + const prNumber = context.payload.workflow_run.pull_requests[0].number; + const jobLogsUrl = `${baseUrl}/actions/runs/${context.payload.workflow_run.id}`; const template = await fs.readFile( './.github/actions/templates/build-status.md', - 'utf8' - ) + 'utf8', + ); /** @type {string[]} */ - const tableRows = [] + const tableRows = []; - core.setOutput('conclusion', conclusion) + core.setOutput('conclusion', conclusion); if (conclusion === 'cancelled') { - return + return; } const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ owner, repo, - run_id: runId - }) + run_id: runId, + }); artifacts.data.artifacts.forEach((artifact) => { - const key = /** @type {Browser} */ (artifact.name.split('-')[1]) + const key = /** @type {Browser} */ (artifact.name.split('-')[1]); ARTIFACTS_DATA[key].url = - `${baseUrl}/suites/${suiteId}/artifacts/${artifact.id}` - ARTIFACTS_DATA[key].size = formatBytes(artifact.size_in_bytes) - }) + `${baseUrl}/suites/${suiteId}/artifacts/${artifact.id}`; + ARTIFACTS_DATA[key].size = formatBytes(artifact.size_in_bytes); + }); Object.keys(ARTIFACTS_DATA).forEach((k) => { - const { name, url, size } = ARTIFACTS_DATA[/** @type {Browser} */ (k)] + const { name, url, size } = ARTIFACTS_DATA[/** @type {Browser} */ (k)]; if (!url && !size) { - const badgeUrl = getBadge('failure', COLORS.red, name) + const badgeUrl = getBadge('failure', COLORS.red, name); tableRows.push( - `${badgeUrl}N/A` - ) + `${badgeUrl}N/A`, + ); } else { - const badgeUrl = getBadge('success', COLORS.green, `${name} (${size})`) + const badgeUrl = getBadge('success', COLORS.green, `${name} (${size})`); tableRows.push( - `${badgeUrl}Download` - ) + `${badgeUrl}Download`, + ); } - }) + }); - const tableBody = tableRows.join('') + const tableBody = tableRows.join(''); const commentBody = template .replace(TEMPLATE_VARS.conclusion, conclusion) .replace(TEMPLATE_VARS.sha, sha) .replace(TEMPLATE_VARS.jobLogs, `Run #${runId}`) - .replace(TEMPLATE_VARS.tableBody, tableBody) + .replace(TEMPLATE_VARS.tableBody, tableBody); - core.setOutput('comment_body', commentBody) - core.setOutput('pr_number', prNumber) -} + core.setOutput('comment_body', commentBody); + core.setOutput('pr_number', prNumber); +}; diff --git a/.github/actions/validate-stable-release.cjs b/.github/actions/validate-stable-release.cjs index d3f3ebd0..2eeba23f 100644 --- a/.github/actions/validate-stable-release.cjs +++ b/.github/actions/validate-stable-release.cjs @@ -7,34 +7,36 @@ */ module.exports = async ({ github, context }) => { if (context.ref !== 'refs/heads/main') { - throw new Error('This action only works on main branch') + throw new Error('This action only works on main branch'); } - const { owner, repo } = context.repo - const previewVersionTag = process.env.INPUT_VERSION + const { owner, repo } = context.repo; + const previewVersionTag = process.env.INPUT_VERSION; if (!previewVersionTag) { - throw new Error('Missing env.INPUT_VERSION') + throw new Error('Missing env.INPUT_VERSION'); } if (!previewVersionTag.match(/^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+-preview$/)) { - throw new Error('Input "version" must match vX.X.X.X-preview') + throw new Error('Input "version" must match vX.X.X.X-preview'); } - const versionTag = previewVersionTag.replace('-preview', '') + const versionTag = previewVersionTag.replace('-preview', ''); try { await github.rest.repos.getReleaseByTag({ owner, repo, - tag: versionTag - }) - throw new Error('Release already promoted to stable') + tag: versionTag, + }); + throw new Error('Release already promoted to stable'); } catch (error) { if (!error.status) { - throw error + throw error; } if (error.status === 404) { // do nothing } else { - throw new Error(`Failed to check: HTTP ${error.status}`, { cause: error }) + throw new Error(`Failed to check: HTTP ${error.status}`, { + cause: error, + }); } } -} +}; diff --git a/.github/labeler.yml b/.github/labeler.yml index 02e1481b..fc855b1e 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -29,6 +29,7 @@ - any: - changed-files: - any-glob-to-any-file: '**/*.test.ts' + - any-glob-to-any-file: 'tests/**/*' 'area: i18n': - any: diff --git a/.github/workflows/nightly-build.yaml b/.github/workflows/nightly-build.yaml index bb681abe..7ae44196 100644 --- a/.github/workflows/nightly-build.yaml +++ b/.github/workflows/nightly-build.yaml @@ -14,9 +14,67 @@ defaults: shell: bash jobs: + test-e2e: + strategy: + fail-fast: false + matrix: + include: + - name: Chrome + project: chrome + target: chrome + runs-on: ubuntu-22.04 + - name: Edge + project: msedge + target: chrome + runs-on: ubuntu-22.04 + timeout-minutes: 15 + name: E2E Tests - ${{ matrix.name }} + runs-on: ${{ matrix.runs-on }} + environment: test + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Environment setup + uses: ./.github/actions/setup + + - name: Build + run: pnpm build ${{ matrix.target }} --channel=nightly + + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + + - name: Run Playwright tests + run: pnpm test:e2e:${{ matrix.project }} + env: + PLAYWRIGHT_PROJECT: ${{ matrix.project }} + PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1' + WALLET_URL_ORIGIN: ${{ vars.E2E_WALLET_URL_ORIGIN }} + WALLET_USERNAME: ${{ vars.E2E_WALLET_USERNAME }} + WALLET_PASSWORD: ${{ secrets.E2E_WALLET_PASSWORD }} + CONNECT_WALLET_ADDRESS_URL: ${{ vars.E2E_CONNECT_WALLET_ADDRESS_URL }} + CONNECT_KEY_ID: ${{ vars.E2E_CONNECT_KEY_ID }} + CONNECT_PUBLIC_KEY: ${{ secrets.E2E_CONNECT_PUBLIC_KEY }} + CONNECT_PRIVATE_KEY: ${{ secrets.E2E_CONNECT_PRIVATE_KEY }} + + - name: Encrypt report + shell: bash + working-directory: tests/e2e/playwright-report + run: | + zip -r -P ${{ secrets.E2E_PLAYWRIGHT_REPORT_PASSWORD }} ../playwright-report.zip * + + - name: Upload report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-${{ matrix.project }} + path: tests/e2e/playwright-report.zip + retention-days: 3 + build-nightly: name: Create Release runs-on: ubuntu-22.04 + needs: test-e2e steps: - name: Checkout repository uses: actions/checkout@v4 @@ -52,3 +110,8 @@ jobs: > [!warning] > The Nightly build is for adventurous folks. It's updated daily with less-tested features and improvements. prerelease: true + + - name: Ensure release is published + run: gh release edit nightly --draft=false + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml new file mode 100644 index 00000000..fd7b82ec --- /dev/null +++ b/.github/workflows/tests-e2e.yml @@ -0,0 +1,70 @@ +name: End-to-End Tests +on: + pull_request_review: + types: [submitted] + +jobs: + test-e2e: + if: ${{ + github.event.review.body == 'test-e2e' && + contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association) + }} + strategy: + fail-fast: false + matrix: + include: + - name: Chrome + project: chrome + target: chrome + runs-on: ubuntu-22.04 + # - name: Firefox + # project: firefox + # target: firefox + # runs-on: ubuntu-22.04 + - name: Edge + project: msedge + target: chrome + runs-on: ubuntu-22.04 + + timeout-minutes: 15 + name: E2E Tests - ${{ matrix.name }} + runs-on: ${{ matrix.runs-on }} + environment: test + steps: + - uses: actions/checkout@v4 + + - name: Environment setup + uses: ./.github/actions/setup + + - name: Build + run: pnpm build ${{ matrix.target }} --channel=nightly + + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + + - name: Run Playwright tests + run: pnpm test:e2e:${{ matrix.project }} + env: + PLAYWRIGHT_PROJECT: ${{ matrix.project }} + PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS: '1' + WALLET_URL_ORIGIN: ${{ vars.E2E_WALLET_URL_ORIGIN }} + WALLET_USERNAME: ${{ vars.E2E_WALLET_USERNAME }} + WALLET_PASSWORD: ${{ secrets.E2E_WALLET_PASSWORD }} + CONNECT_WALLET_ADDRESS_URL: ${{ vars.E2E_CONNECT_WALLET_ADDRESS_URL }} + CONNECT_KEY_ID: ${{ vars.E2E_CONNECT_KEY_ID }} + CONNECT_PUBLIC_KEY: ${{ secrets.E2E_CONNECT_PUBLIC_KEY }} + CONNECT_PRIVATE_KEY: ${{ secrets.E2E_CONNECT_PRIVATE_KEY }} + + - name: Encrypt report + shell: bash + working-directory: tests/e2e/playwright-report + run: | + zip -r -P ${{ secrets.E2E_PLAYWRIGHT_REPORT_PASSWORD }} ../playwright-report.zip * + + - name: Upload report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-${{ matrix.project }} + path: tests/e2e/playwright-report.zip + retention-days: 3 diff --git a/.gitignore b/.gitignore index af7f9d5e..756d299c 100755 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ dist-firefox-v2 public/manifest.json *.local coverage +.env .husky .vscode @@ -30,3 +31,9 @@ coverage *.njsproj *.sln *.sw? + +# playwright +/tests/e2e/test-results/ +/tests/e2e/test-results/.auth +/tests/e2e/playwright-report/ +/playwright/.cache/ diff --git a/.nvmrc b/.nvmrc index 8ce70308..3516580b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.16.0 +20.17.0 diff --git a/.prettierrc.js b/.prettierrc.js index 43ce286a..1c453e54 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,8 +1,8 @@ module.exports = { singleQuote: true, - trailingComma: 'none', + trailingComma: 'all', jsxSingleQuote: false, - semi: false, + semi: true, plugins: ['prettier-plugin-tailwindcss'], tailwindFunctions: [ 'classnames', @@ -13,6 +13,6 @@ module.exports = { 'twStyle', 'twMerge', 'twJoin', - 'cn' - ] -} + 'cn', + ], +}; diff --git a/README.md b/README.md index d0b09bcf..2d594ab8 100755 --- a/README.md +++ b/README.md @@ -75,19 +75,22 @@ All commands are run from the root of the project, from a terminal: Inside this project, you'll see the following folders and files: -``` +```sh . ├── .github/ # GitHub Workflows +├── docs/ # Repository documentation +├── esbuild/ # Esbuild configuration ├── scripts/ # Script to build the extension (production, development) ├── src/ # Extension's source code │ ├── _locales/ # Files for multi-lang support │ ├── assets/ # Images for the extension (icon, etc.) │ ├── background/ # Source code for the background script/service worker -│ ├── content/ # Source code for the content script +│ ├── content/ # Source code for the content scripts +│ │ └── keyAutoAdd/ # content scripts for automatic key addition to wallets │ ├── popup/ # Source code for the popup UI +│ ├── pages/ # Source code for additional extension pages │ ├── shared/ # Shared utilities │ └── manifest.json # Extension's manifest - processed by Webpack depending on the target build -├── webpack/ # Webpack configuration ├── jest.config.ts ├── jest.setup.ts ├── package.json diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index 866e49a1..4637d1a5 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -16,6 +16,8 @@ unmangles data-testid nums webmonetization +jwks +requestfinished # scripts and 3rd party terms nvmrc @@ -30,6 +32,10 @@ backported autobuild buildscript Camtasia +networkidle +webextensions +firefoxUserPrefs +textbox # packages and 3rd party tools/libraries awilix @@ -38,6 +44,8 @@ loglevel openapi apidevtools tailwindcss +msedge +xvfb # user names raducristianpopa @@ -46,6 +54,7 @@ dianafulga jgoz amannn softprops +Gidarakos # monetized website domain names ahimsakids diff --git a/cspell.json b/cspell.json index 503ba166..76c28f53 100644 --- a/cspell.json +++ b/cspell.json @@ -18,6 +18,7 @@ "*.svg", "pnpm-lock.yaml", ".eslintrc.json", + ".gitignore", "cspell-dictionary.txt" ] } diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 00000000..1af731c1 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,58 @@ +# Automated Testing + +## Unit tests + +Run `pnpm test` to run unit tests locally. These tests are run automatically on every pull request. + +## End-to-end Tests + +To run end-to-end tests, run `pnpm test:e2e` in terminal. To run tests with Chrome only, run `pnpm test:e2e:chrome`. + +Make sure you run `pnpm build chrome` before running tests. + +**Before you begin**, you need to setup some environment variables/secrets in `tests/.env`. + +1. Copy `tests/.env.example` to `tests/.env` +2. Update `tests/.env` with your secrets. + +| Environment Variable | Description | Is secret? | +| ---------------------------- | ----------------------------------------------------------- | ---------- | +| `WALLET_URL_ORIGIN` | URL of the wallet (e.g. https://rafiki.money) | No | +| `WALLET_USERNAME` | Login email for the wallet | No | +| `WALLET_PASSWORD` | Login password for the wallet | Yes | +| `CONNECT_WALLET_ADDRESS_URL` | Your wallet address that will be connected to extension | No | +| `CONNECT_KEY_ID` | ID of the key that will be connected to extension (UUID v4) | No | +| `CONNECT_PRIVATE_KEY` | Private key (hex-encoded Ed25519 private key) | Yes | +| `CONNECT_PUBLIC_KEY` | Public key (base64-encoded Ed25519 public key) | No | + +To get the `CONNECT_KEY_ID`, `CONNECT_PRIVATE_KEY` and `CONNECT_PUBLIC_KEY`: + +1. Load the extension in browser (via `chrome://extensions/`) + - Once the extension is loaded, it'll generate a key-pair that we will need to connect with our wallet. +1. Inspect service worker with "Inspect views service worker" +1. Run following in devtools console to copy keys to your clipboard, and paste it in `tests/.env`: + ```js + // 1. Gets generated keys from extension storage. + // 2. Converts result to `CONNECT_{X}="VAL"` format for use in .env file. + // 3. Copies result to clipboard. + copy( + Object.entries( + await chrome.storage.local.get(['privateKey', 'publicKey', 'keyId']), + ) + .map( + ([k, v]) => + `CONNECT_${k.replace(/([A-Z])/g, '_$1').toUpperCase()}="${v}"`, + ) + .join('\n'), + ); + ``` +1. Then copy `CONNECT_PUBLIC_KEY` key to https://rafiki.money/settings/developer-keys under your wallet address. +1. Now you're ready to run the tests. + +### How to run in end-to-end tests in GitHub + +As these tests are expensive/time-consuming, these need to be triggered manually when needed, instead of on every pull request/commit. + +For a pull request, users with write access to repository can trigger the workflow to run end-to-end tests by adding a review-comment (from PR Files tab) with body `test-e2e` (exactly). + +End-to-end tests run automatically daily before creating the Nightly release. You can also trigger that workflow manually from [Actions Dashboard](https://github.com/interledger/web-monetization-extension/actions/workflows/nightly-build.yaml). diff --git a/esbuild/config.ts b/esbuild/config.ts index 5f99811c..26ff7f7e 100644 --- a/esbuild/config.ts +++ b/esbuild/config.ts @@ -1,41 +1,49 @@ -import path from 'node:path' -import type { BuildOptions } from 'esbuild' -import type { Manifest } from 'webextension-polyfill' +import path from 'node:path'; +import type { BuildOptions } from 'esbuild'; +import type { Manifest } from 'webextension-polyfill'; -export const TARGETS = ['chrome', 'firefox'] as const -export const CHANNELS = ['nightly', 'preview', 'stable'] as const +export const TARGETS = ['chrome', 'firefox'] as const; +export const CHANNELS = ['nightly', 'preview', 'stable'] as const; -export const ROOT_DIR = path.resolve(__dirname, '..') -export const SRC_DIR = path.resolve(ROOT_DIR, 'src') -export const DEV_DIR = path.resolve(ROOT_DIR, 'dev') -export const DIST_DIR = path.resolve(ROOT_DIR, 'dist') +export const ROOT_DIR = path.resolve(__dirname, '..'); +export const SRC_DIR = path.resolve(ROOT_DIR, 'src'); +export const DEV_DIR = path.resolve(ROOT_DIR, 'dev'); +export const DIST_DIR = path.resolve(ROOT_DIR, 'dist'); -export type Target = (typeof TARGETS)[number] -export type Channel = (typeof CHANNELS)[number] +export type Target = (typeof TARGETS)[number]; +export type Channel = (typeof CHANNELS)[number]; export type BuildArgs = { - target: Target - channel: Channel - dev: boolean -} + target: Target; + channel: Channel; + dev: boolean; +}; export const options: BuildOptions = { entryPoints: [ { in: path.join(SRC_DIR, 'background', 'index.ts'), - out: path.join('background', 'background') + out: path.join('background', 'background'), }, { in: path.join(SRC_DIR, 'content', 'index.ts'), - out: path.join('content', 'content') + out: path.join('content', 'content'), + }, + { + in: path.join(SRC_DIR, 'content', 'keyAutoAdd', 'testWallet.ts'), + out: path.join('content', 'keyAutoAdd', 'testWallet'), }, { in: path.join(SRC_DIR, 'content', 'polyfill.ts'), - out: path.join('polyfill', 'polyfill') + out: path.join('polyfill', 'polyfill'), }, { in: path.join(SRC_DIR, 'popup', 'index.tsx'), - out: path.join('popup', 'popup') - } + out: path.join('popup', 'popup'), + }, + { + in: path.join(SRC_DIR, 'pages', 'progress-connect', 'index.tsx'), + out: path.join('pages', 'progress-connect', 'progress-connect'), + }, ], bundle: true, legalComments: 'none', @@ -44,14 +52,14 @@ export const options: BuildOptions = { format: 'iife', write: true, logLevel: 'info', - treeShaking: true -} + treeShaking: true, +}; export type WebExtensionManifest = Manifest.WebExtensionManifest & { - background: Manifest.WebExtensionManifestBackgroundC3Type -} + background: Manifest.WebExtensionManifestBackgroundC3Type; +}; export const SERVE_PORTS: Record = { chrome: 7000, - firefox: 7002 -} + firefox: 7002, +}; diff --git a/esbuild/dev.ts b/esbuild/dev.ts index d5b7bf46..02544d17 100644 --- a/esbuild/dev.ts +++ b/esbuild/dev.ts @@ -1,15 +1,15 @@ -import { readFile } from 'node:fs/promises' -import type { BuildOptions, Plugin as ESBuildPlugin } from 'esbuild' -import { SERVE_PORTS, type BuildArgs, type Target } from './config' -import { getPlugins } from './plugins' -import { typecheckPlugin } from '@jgoz/esbuild-plugin-typecheck' +import { readFile } from 'node:fs/promises'; +import type { BuildOptions, Plugin as ESBuildPlugin } from 'esbuild'; +import { SERVE_PORTS, type BuildArgs, type Target } from './config'; +import { getPlugins } from './plugins'; +import { typecheckPlugin } from '@jgoz/esbuild-plugin-typecheck'; export const getDevOptions = ({ outDir, target, - channel + channel, }: Omit & { - outDir: string + outDir: string; }): BuildOptions => { return { sourcemap: 'linked', @@ -17,24 +17,24 @@ export const getDevOptions = ({ minify: false, plugins: getPlugins({ outDir, dev: true, target, channel }).concat([ typecheckPlugin({ buildMode: 'readonly', watch: true }), - liveReloadPlugin({ target }) + liveReloadPlugin({ target }), ]), define: { NODE_ENV: JSON.stringify('development'), CONFIG_LOG_LEVEL: JSON.stringify('DEBUG'), CONFIG_PERMISSION_HOSTS: JSON.stringify({ - origins: ['http://*/*', 'https://*/*'] + origins: ['http://*/*', 'https://*/*'], }), CONFIG_ALLOWED_PROTOCOLS: JSON.stringify(['http:', 'https:']), CONFIG_OPEN_PAYMENTS_REDIRECT_URL: JSON.stringify( - 'https://webmonetization.org/welcome' - ) - } - } -} + 'https://webmonetization.org/welcome', + ), + }, + }; +}; function liveReloadPlugin({ target }: { target: Target }): ESBuildPlugin { - const port = SERVE_PORTS[target] + const port = SERVE_PORTS[target]; const reloadScriptBackground = ` new EventSource("http://localhost:${port}/esbuild").addEventListener( "change", @@ -49,7 +49,7 @@ function liveReloadPlugin({ target }: { target: Target }): ESBuildPlugin { await browser.runtime.reload(); } } - );` + );`; const reloadScriptPopup = ` new EventSource("http://localhost:${port}/esbuild").addEventListener( @@ -63,26 +63,66 @@ function liveReloadPlugin({ target }: { target: Target }): ESBuildPlugin { globalThis.location.reload(); } } - );` + );`; + + const reloadScriptPages = ` + new EventSource("http://localhost:${port}/esbuild").addEventListener( + "change", + (ev) => { + const data = JSON.parse(ev.data); + if ( + data.added.some(s => s.includes("/pages/")) || + data.updated.some(s => s.includes("/pages/")) + ) { + globalThis.location.reload(); + } + } + );`; + + const reloadScriptContent = ` + new EventSource("http://localhost:${port}/esbuild").addEventListener( + "change", + (ev) => { + const patterns = ["background.js", "content.js", "polyfill.js", "keyAutoAdd/"]; + const data = JSON.parse(ev.data); + if (data.updated.some((s) => patterns.some(e => s.includes(e)))) { + globalThis.location.reload(); + } + }, + );`; return { name: 'live-reload', setup(build) { build.onLoad({ filter: /src\/background\/index\.ts$/ }, async (args) => { - const contents = await readFile(args.path, 'utf8') + const contents = await readFile(args.path, 'utf8'); return { contents: reloadScriptBackground + '\n' + contents, - loader: 'ts' as const - } - }) + loader: 'ts' as const, + }; + }); build.onLoad({ filter: /src\/popup\/index\.tsx$/ }, async (args) => { - const contents = await readFile(args.path, 'utf8') + const contents = await readFile(args.path, 'utf8'); return { contents: contents + '\n\n\n' + reloadScriptPopup, - loader: 'tsx' as const - } - }) - } - } + loader: 'tsx' as const, + }; + }); + build.onLoad({ filter: /src\/pages\/.+\/index.tsx$/ }, async (args) => { + const contents = await readFile(args.path, 'utf8'); + return { + contents: contents + '\n\n\n' + reloadScriptPages, + loader: 'tsx' as const, + }; + }); + build.onLoad({ filter: /src\/content\// }, async (args) => { + const contents = await readFile(args.path, 'utf8'); + return { + contents: contents + '\n\n\n' + reloadScriptContent, + loader: 'ts' as const, + }; + }); + }, + }; } diff --git a/esbuild/plugins.ts b/esbuild/plugins.ts index 73cdfa3b..34b96574 100644 --- a/esbuild/plugins.ts +++ b/esbuild/plugins.ts @@ -1,26 +1,26 @@ -import path from 'node:path' -import fs from 'node:fs/promises' -import type { Plugin as ESBuildPlugin } from 'esbuild' -import { nodeBuiltin } from 'esbuild-node-builtin' -import esbuildStylePlugin from 'esbuild-style-plugin' -import { copy } from 'esbuild-plugin-copy' -import tailwind from 'tailwindcss' -import autoprefixer from 'autoprefixer' +import path from 'node:path'; +import fs from 'node:fs/promises'; +import type { Plugin as ESBuildPlugin } from 'esbuild'; +import { nodeBuiltin } from 'esbuild-node-builtin'; +import esbuildStylePlugin from 'esbuild-style-plugin'; +import { copy } from 'esbuild-plugin-copy'; +import tailwind from 'tailwindcss'; +import autoprefixer from 'autoprefixer'; import { SRC_DIR, ROOT_DIR, type BuildArgs, - type WebExtensionManifest -} from './config' + type WebExtensionManifest, +} from './config'; export const getPlugins = ({ outDir, target, channel, - dev + dev, }: BuildArgs & { - outDir: string + outDir: string; }): ESBuildPlugin[] => { return [ cleanPlugin([outDir]), @@ -33,38 +33,42 @@ export const getPlugins = ({ name: 'crypto-for-extension', setup(build) { build.onResolve({ filter: /^crypto$/ }, () => ({ - path: require.resolve('crypto-browserify') - })) - } + path: require.resolve('crypto-browserify'), + })); + }, }, ignorePackagePlugin([/@apidevtools[/|\\]json-schema-ref-parser/]), esbuildStylePlugin({ extract: true, postcss: { - plugins: [tailwind, autoprefixer] - } + plugins: [tailwind, autoprefixer], + }, }), copy({ resolveFrom: ROOT_DIR, assets: [ { from: path.join(SRC_DIR, 'popup', 'index.html'), - to: path.join(outDir, 'popup', 'index.html') + to: path.join(outDir, 'popup', 'index.html'), + }, + { + from: path.join(SRC_DIR, 'pages', 'progress-connect', 'index.html'), + to: path.join(outDir, 'pages', 'progress-connect', 'index.html'), }, { from: path.join(SRC_DIR, '_locales/**/*'), - to: path.join(outDir, '_locales') + to: path.join(outDir, '_locales'), }, { from: path.join(SRC_DIR, 'assets/**/*'), - to: path.join(outDir, 'assets') - } + to: path.join(outDir, 'assets'), + }, ], - watch: dev + watch: dev, }), - processManifestPlugin({ outDir, dev, target, channel }) - ] -} + processManifestPlugin({ outDir, dev, target, channel }), + ]; +}; // Based on https://github.com/Knowre-Dev/esbuild-plugin-ignore function ignorePackagePlugin(ignores: RegExp[]): ESBuildPlugin { @@ -73,59 +77,59 @@ function ignorePackagePlugin(ignores: RegExp[]): ESBuildPlugin { setup(build) { build.onResolve({ filter: /.*/, namespace: 'ignore' }, (args) => ({ path: args.path, - namespace: 'ignore' - })) + namespace: 'ignore', + })); for (const ignorePattern of ignores) { build.onResolve({ filter: ignorePattern }, (args) => { - return { path: args.path, namespace: 'ignore' } - }) + return { path: args.path, namespace: 'ignore' }; + }); } build.onLoad({ filter: /.*/, namespace: 'ignore' }, () => ({ - contents: '' - })) - } - } + contents: '', + })); + }, + }; } function processManifestPlugin({ outDir, target, channel, - dev + dev, }: BuildArgs & { outDir: string }): ESBuildPlugin { return { name: 'process-manifest', setup(build) { build.onEnd(async () => { - const src = path.join(SRC_DIR, 'manifest.json') - const dest = path.join(outDir, 'manifest.json') + const src = path.join(SRC_DIR, 'manifest.json'); + const dest = path.join(outDir, 'manifest.json'); const json = JSON.parse( - await fs.readFile(src, 'utf8') - ) as WebExtensionManifest + await fs.readFile(src, 'utf8'), + ) as WebExtensionManifest; // Transform manifest as targets have different expectations // @ts-expect-error Only for IDE. No target accepts it - delete json['$schema'] + delete json['$schema']; if (channel === 'nightly') { // Set version to YYYY.M.D - const now = new Date() + const now = new Date(); const [year, month, day] = [ now.getFullYear(), now.getMonth() + 1, - now.getDate() - ] - json.version = `${year}.${month}.${day}` + now.getDate(), + ]; + json.version = `${year}.${month}.${day}`; if (target !== 'firefox') { - json.version_name = `Nightly ${json.version}` + json.version_name = `Nightly ${json.version}`; } } if (channel === 'preview') { - json.name = json.name + ' Preview' + json.name = json.name + ' Preview'; } else if (channel === 'nightly') { - json.name = json.name + ' Nightly' + json.name = json.name + ' Nightly'; } if (dev) { @@ -133,34 +137,29 @@ function processManifestPlugin({ json.host_permissions && !json.host_permissions.includes('http://*/*') ) { - json.host_permissions.push('http://*/*') + json.host_permissions.push('http://*/*'); } json.content_scripts?.forEach((contentScript) => { if (!contentScript.matches.includes('http://*/*')) { - contentScript.matches.push('http://*/*') + contentScript.matches.push('http://*/*'); } - }) + }); } if (target === 'firefox') { // @ts-expect-error Firefox doesn't support Service Worker in MV3 yet json.background = { - scripts: [json.background.service_worker] - } - json.content_scripts?.forEach((contentScript) => { - // TODO: Remove this when Firefox supports `world` - at least last 10 - // versions - contentScript.world = undefined - }) - delete json.minimum_chrome_version + scripts: [json.background.service_worker], + }; + delete json.minimum_chrome_version; } else { - delete json['browser_specific_settings'] + delete json['browser_specific_settings']; } - await fs.writeFile(dest, JSON.stringify(json, null, 2)) - }) - } - } + await fs.writeFile(dest, JSON.stringify(json, null, 2)); + }); + }, + }; } function cleanPlugin(dirs: string[]): ESBuildPlugin { @@ -169,9 +168,9 @@ function cleanPlugin(dirs: string[]): ESBuildPlugin { setup(build) { build.onStart(async () => { await Promise.all( - dirs.map((dir) => fs.rm(dir, { recursive: true, force: true })) - ) - }) - } - } + dirs.map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + }, + }; } diff --git a/esbuild/prod.ts b/esbuild/prod.ts index e6c8a32c..8355605a 100644 --- a/esbuild/prod.ts +++ b/esbuild/prod.ts @@ -1,19 +1,24 @@ /* eslint-disable no-console */ -import fs from 'node:fs/promises' -import { createWriteStream } from 'node:fs' -import path from 'node:path' -import type { BuildOptions, Plugin as ESBuildPlugin } from 'esbuild' -import archiver from 'archiver' -import type { BuildArgs, Channel, Target, WebExtensionManifest } from './config' -import { getPlugins } from './plugins' -import { typecheckPlugin } from '@jgoz/esbuild-plugin-typecheck' +import fs from 'node:fs/promises'; +import { createWriteStream } from 'node:fs'; +import path from 'node:path'; +import type { BuildOptions, Plugin as ESBuildPlugin } from 'esbuild'; +import archiver from 'archiver'; +import type { + BuildArgs, + Channel, + Target, + WebExtensionManifest, +} from './config'; +import { getPlugins } from './plugins'; +import { typecheckPlugin } from '@jgoz/esbuild-plugin-typecheck'; export const getProdOptions = ({ outDir, target, - channel + channel, }: Omit & { - outDir: string + outDir: string; }): BuildOptions => { return { sourcemap: false, @@ -22,7 +27,7 @@ export const getProdOptions = ({ plugins: getPlugins({ outDir, dev: false, target, channel }).concat([ typecheckPlugin({ buildMode: 'readonly' }), preservePolyfillClassNamesPlugin({ outDir }), - zipPlugin({ outDir, target, channel }) + zipPlugin({ outDir, target, channel }), ]), define: { NODE_ENV: JSON.stringify('production'), @@ -30,92 +35,92 @@ export const getProdOptions = ({ CONFIG_PERMISSION_HOSTS: JSON.stringify({ origins: ['https://*/*'] }), CONFIG_ALLOWED_PROTOCOLS: JSON.stringify(['https:']), CONFIG_OPEN_PAYMENTS_REDIRECT_URL: JSON.stringify( - 'https://webmonetization.org/welcome' - ) - } - } -} + 'https://webmonetization.org/welcome', + ), + }, + }; +}; function zipPlugin({ outDir, target, - channel + channel, }: { - channel: Channel - target: Target - outDir: string + channel: Channel; + target: Target; + outDir: string; }): ESBuildPlugin { return { name: 'zip', setup(build) { build.onEnd(async () => { const manifest = JSON.parse( - await fs.readFile(path.join(outDir, 'manifest.json'), 'utf8') - ) as WebExtensionManifest + await fs.readFile(path.join(outDir, 'manifest.json'), 'utf8'), + ) as WebExtensionManifest; - let zipName = `${target}-${manifest.version}.zip` + let zipName = `${target}-${manifest.version}.zip`; if (channel !== 'stable') { - zipName = `${channel}-${zipName}` + zipName = `${channel}-${zipName}`; } - const dest = path.join(outDir, '..', zipName) - const output = createWriteStream(dest) - const archive = archiver('zip') + const dest = path.join(outDir, '..', zipName); + const output = createWriteStream(dest); + const archive = archiver('zip'); archive.on('end', function () { - const archiveSize = archive.pointer() - const fileName = path.relative(process.cwd(), dest) - console.log(` Archived ${fileName}: ${formatBytes(archiveSize)}`) - }) - archive.pipe(output) - archive.glob('**/*', { cwd: outDir, ignore: ['meta.json'] }) - await archive.finalize() - }) - } - } + const archiveSize = archive.pointer(); + const fileName = path.relative(process.cwd(), dest); + console.log(` Archived ${fileName}: ${formatBytes(archiveSize)}`); + }); + archive.pipe(output); + archive.glob('**/*', { cwd: outDir, ignore: ['meta.json'] }); + await archive.finalize(); + }); + }, + }; } /** * Unmangles the MonetizationEvent class */ function preservePolyfillClassNamesPlugin({ - outDir + outDir, }: { - outDir: string + outDir: string; }): ESBuildPlugin { return { name: 'preserve-polyfill-class-names', setup(build) { build.onEnd(async () => { - const polyfillPath = path.join(outDir, 'polyfill', 'polyfill.js') - const polyfillContent = await fs.readFile(polyfillPath, 'utf8') - const definitionRegex = /class\s+([A-Za-z_$][\w$]*)\s+extends\s+Event/ + const polyfillPath = path.join(outDir, 'polyfill', 'polyfill.js'); + const polyfillContent = await fs.readFile(polyfillPath, 'utf8'); + const definitionRegex = /class\s+([A-Za-z_$][\w$]*)\s+extends\s+Event/; - const match = polyfillContent.match(definitionRegex) + const match = polyfillContent.match(definitionRegex); if (!match) { - throw new Error('Could not find MonetizationEvent definition') + throw new Error('Could not find MonetizationEvent definition'); } - const minifiedName = match[1] + const minifiedName = match[1]; const result = polyfillContent .replace(definitionRegex, `class MonetizationEvent extends Event`) .replace( `window.MonetizationEvent=${minifiedName}`, - `window.MonetizationEvent=MonetizationEvent` + `window.MonetizationEvent=MonetizationEvent`, ) - .replaceAll(`new ${minifiedName}`, 'new MonetizationEvent') + .replaceAll(`new ${minifiedName}`, 'new MonetizationEvent'); - await fs.writeFile(polyfillPath, result) - }) - } - } + await fs.writeFile(polyfillPath, result); + }); + }, + }; } function formatBytes(bytes: number, decimals: number = 2) { - if (!Number(bytes)) return '0B' - const k = 1024 - const dm = decimals < 0 ? 0 : decimals - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))}${sizes[i]}` + if (!Number(bytes)) return '0B'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))}${sizes[i]}`; } diff --git a/jest.config.ts b/jest.config.ts index f911435f..e625acaf 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -7,25 +7,26 @@ export default { '!src/**/*.css', '!src/**/*.svg', '!src/**/*.d.ts', - '!src/**/index.ts' + '!src/**/index.ts', ], coverageDirectory: 'coverage', coverageProvider: 'v8', maxWorkers: '50%', moduleFileExtensions: ['js', 'jsx', 'json', 'ts', 'tsx'], moduleNameMapper: { - '@/(.*)': '/src/$1' + '@/(.*)': '/src/$1', }, setupFilesAfterEnv: ['./jest.setup.ts'], testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'], testEnvironment: 'jsdom', testPathIgnorePatterns: [ '/node_modules/', - '/jest.config.ts' + '/tests/', + '/jest.config.ts', ], transform: { '^.+\\.(js|jsx)$': 'babel-jest', '^.+\\.(ts|tsx)?$': 'ts-jest', - '\\.(css|less|scss|sass|svg)$': 'jest-transform-stub' - } -} + '\\.(css|less|scss|sass|svg)$': 'jest-transform-stub', + }, +}; diff --git a/jest.setup.ts b/jest.setup.ts index 60d87370..e524c769 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,5 +1,5 @@ -import '@testing-library/jest-dom' +import '@testing-library/jest-dom'; -import { chrome } from 'jest-chrome' +import { chrome } from 'jest-chrome'; -Object.assign(global, { chrome: chrome, browser: chrome }) +Object.assign(global, { chrome: chrome, browser: chrome }); diff --git a/package.json b/package.json index 0541ec34..2c41578d 100644 --- a/package.json +++ b/package.json @@ -17,75 +17,82 @@ "format:fix": "prettier . --write --cache --cache-location='node_modules/.cache/prettiercache' --log-level=warn", "typecheck": "tsc --noEmit", "test": "jest --maxWorkers=2 --passWithNoTests", + "test:e2e": "playwright test", + "test:e2e:chrome": "playwright test --project=chrome", + "test:e2e:msedge": "playwright test --project=msedge", + "test:e2e:report": "playwright show-report tests/e2e/playwright-report", "test:ci": "pnpm test -- --reporters=default --reporters=github-actions" }, "dependencies": { "@interledger/open-payments": "^6.13.1", "@noble/ed25519": "^2.1.0", - "@noble/hashes": "^1.4.0", - "awilix": "^10.0.2", + "@noble/hashes": "^1.5.0", + "awilix": "^11.0.0", "class-variance-authority": "^0.7.0", "crypto-browserify": "^3.12.0", "date-fns": "^3.6.0", - "framer-motion": "^11.3.28", + "framer-motion": "^11.7.0", "http-message-signatures": "^1.0.4", "httpbis-digest-headers": "^1.0.0", "iso8601-duration": "^2.1.2", - "loglevel": "^1.9.1", + "loglevel": "^1.9.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-hook-form": "^7.52.2", - "react-router-dom": "^6.26.1", + "react-hook-form": "^7.53.0", + "react-router-dom": "^6.26.2", "safe-buffer": "5.2.1", "tailwind-merge": "^2.5.2", "webextension-polyfill": "^0.12.0" }, "devDependencies": { "@jgoz/esbuild-plugin-typecheck": "^4.0.1", - "@tailwindcss/forms": "^0.5.7", - "@testing-library/jest-dom": "^6.4.8", - "@testing-library/react": "^16.0.0", + "@playwright/test": "^1.47.2", + "@tailwindcss/forms": "^0.5.9", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", "@types/archiver": "^6.0.2", + "@types/chrome": "^0.0.272", "@types/github-script": "github:actions/github-script", - "@types/jest": "^29.5.12", - "@types/node": "^20.16.1", - "@types/react": "^18.3.4", + "@types/jest": "^29.5.13", + "@types/node": "^20.16.9", + "@types/react": "^18.3.9", "@types/react-dom": "^18.3.0", "@types/react-router-dom": "^5.3.3", - "@types/webextension-polyfill": "^0.12.0", - "@typescript-eslint/eslint-plugin": "^8.2.0", - "@typescript-eslint/parser": "^8.2.0", + "@types/webextension-polyfill": "^0.12.1", + "@typescript-eslint/eslint-plugin": "^8.7.0", + "@typescript-eslint/parser": "^8.7.0", "archiver": "^7.0.1", "autoprefixer": "^10.4.20", - "esbuild": "^0.23.1", + "dotenv": "^16.4.5", + "esbuild": "^0.24.0", "esbuild-node-builtin": "^0.1.1", "esbuild-plugin-copy": "^2.1.1", "esbuild-style-plugin": "^1.6.3", - "eslint": "^8.57.0", - "eslint-plugin-html": "^8.1.1", - "eslint-plugin-jest": "^28.8.0", - "eslint-plugin-jsx-a11y": "^6.9.0", - "eslint-plugin-n": "^17.10.2", - "eslint-plugin-react": "^7.35.0", + "eslint": "^8.57.1", + "eslint-plugin-html": "^8.1.2", + "eslint-plugin-jest": "^28.8.3", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-n": "^17.10.3", + "eslint-plugin-react": "^7.36.1", "eslint-plugin-react-hooks": "^4.6.2", "jest": "^29.7.0", "jest-chrome": "^0.8.0", "jest-environment-jsdom": "^29.7.0", "jest-transform-stub": "^2.0.0", - "postcss": "^8.4.41", + "postcss": "^8.4.47", "prettier": "^3.3.3", - "prettier-plugin-tailwindcss": "^0.6.6", + "prettier-plugin-tailwindcss": "^0.6.8", "sade": "^1.8.1", - "tailwindcss": "^3.4.10", - "ts-jest": "^29.2.4", - "tsx": "^4.17.0", - "typescript": "^5.5.4" + "tailwindcss": "^3.4.13", + "ts-jest": "^29.2.5", + "tsx": "^4.19.1", + "typescript": "^5.6.2" }, "engines": { - "pnpm": "^9.7.1", + "pnpm": "^9.10.0", "npm": "pnpm", "yarn": "pnpm", - "node": "^20.16.0" + "node": "^20.17.0" }, "pnpm": { "overrides": { @@ -96,5 +103,5 @@ "structured-headers": "1.0.1" } }, - "packageManager": "pnpm@9.7.1" + "packageManager": "pnpm@9.11.0" } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..6f31cd81 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,78 @@ +import path from 'node:path'; +import { defineConfig, devices } from '@playwright/test'; +import { testDir, authFile } from './tests/e2e/fixtures/helpers'; + +if (!process.env.CI) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('dotenv').config({ path: path.join(testDir, '.env') }); +} + +export default defineConfig({ + testDir, + outputDir: path.join(testDir, 'test-results'), + // We don't want this set to true as that would make tests in each file to run + // in parallel, which will cause conflicts with the "global state". With this + // set to false and workers > 1, multiple test files can run in parallel, but + // tests within a file are run at one at a time. We make extensive use of + // worker-scope fixtures and beforeAll hooks to achieve best performance. + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['list'], + [ + 'html', + { open: 'never', outputFolder: path.join(testDir, 'playwright-report') }, + ], + ], + use: { trace: 'retain-on-failure' }, + + projects: [ + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + }, + + { + name: 'chrome', + use: { + ...devices['Desktop Chrome'], + storageState: authFile, + channel: 'chrome', + }, + dependencies: ['setup'], + }, + + // Firefox+Playwright doesn't work well enough at the moment. + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'], storageState: authFile }, + // dependencies: ['setup'], + // }, + + // Safari is surely a no-go for now + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'], storageState: authFile }, + // dependencies: ['setup'], + // }, + + { + name: 'msedge', + use: { + ...devices['Desktop Edge'], + channel: 'msedge', + storageState: authFile, + }, + dependencies: ['setup'], + }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0e50c5c..d6bf2028 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,11 +22,11 @@ importers: specifier: ^2.1.0 version: 2.1.0 '@noble/hashes': - specifier: ^1.4.0 - version: 1.4.0 + specifier: ^1.5.0 + version: 1.5.0 awilix: - specifier: ^10.0.2 - version: 10.0.2 + specifier: ^11.0.0 + version: 11.0.0 class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -37,8 +37,8 @@ importers: specifier: ^3.6.0 version: 3.6.0 framer-motion: - specifier: ^11.3.28 - version: 11.3.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^11.7.0 + version: 11.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) http-message-signatures: specifier: ^1.0.4 version: 1.0.4 @@ -49,8 +49,8 @@ importers: specifier: ^2.1.2 version: 2.1.2 loglevel: - specifier: ^1.9.1 - version: 1.9.1 + specifier: ^1.9.2 + version: 1.9.2 react: specifier: ^18.3.1 version: 18.3.1 @@ -58,11 +58,11 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) react-hook-form: - specifier: ^7.52.2 - version: 7.52.2(react@18.3.1) + specifier: ^7.53.0 + version: 7.53.0(react@18.3.1) react-router-dom: - specifier: ^6.26.1 - version: 6.26.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^6.26.2 + version: 6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) safe-buffer: specifier: 5.2.1 version: 5.2.1 @@ -75,31 +75,37 @@ importers: devDependencies: '@jgoz/esbuild-plugin-typecheck': specifier: ^4.0.1 - version: 4.0.1(esbuild@0.23.1)(typescript@5.5.4) + version: 4.0.1(esbuild@0.24.0)(typescript@5.6.2) + '@playwright/test': + specifier: ^1.47.2 + version: 1.47.2 '@tailwindcss/forms': - specifier: ^0.5.7 - version: 0.5.7(tailwindcss@3.4.10(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4))) + specifier: ^0.5.9 + version: 0.5.9(tailwindcss@3.4.13(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2))) '@testing-library/jest-dom': - specifier: ^6.4.8 - version: 6.4.8 + specifier: ^6.5.0 + version: 6.5.0 '@testing-library/react': - specifier: ^16.0.0 - version: 16.0.0(@testing-library/dom@10.1.0)(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^16.0.1 + version: 16.0.1(@testing-library/dom@10.1.0)(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/archiver': specifier: ^6.0.2 version: 6.0.2 + '@types/chrome': + specifier: ^0.0.272 + version: 0.0.272 '@types/github-script': specifier: github:actions/github-script - version: github-script@https://codeload.github.com/actions/github-script/tar.gz/35b1cdd1b2c1fc704b1cd9758d10f67e833fcb02 + version: github-script@https://codeload.github.com/actions/github-script/tar.gz/660ec11d825b714d112a6bb9727086bc2cc500b2 '@types/jest': - specifier: ^29.5.12 - version: 29.5.12 + specifier: ^29.5.13 + version: 29.5.13 '@types/node': - specifier: ^20.16.1 - version: 20.16.1 + specifier: ^20.16.9 + version: 20.16.9 '@types/react': - specifier: ^18.3.4 - version: 18.3.4 + specifier: ^18.3.9 + version: 18.3.9 '@types/react-dom': specifier: ^18.3.0 version: 18.3.0 @@ -107,59 +113,62 @@ importers: specifier: ^5.3.3 version: 5.3.3 '@types/webextension-polyfill': - specifier: ^0.12.0 - version: 0.12.0 + specifier: ^0.12.1 + version: 0.12.1 '@typescript-eslint/eslint-plugin': - specifier: ^8.2.0 - version: 8.2.0(@typescript-eslint/parser@8.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4) + specifier: ^8.7.0 + version: 8.7.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2) '@typescript-eslint/parser': - specifier: ^8.2.0 - version: 8.2.0(eslint@8.57.0)(typescript@5.5.4) + specifier: ^8.7.0 + version: 8.7.0(eslint@8.57.1)(typescript@5.6.2) archiver: specifier: ^7.0.1 version: 7.0.1 autoprefixer: specifier: ^10.4.20 - version: 10.4.20(postcss@8.4.41) + version: 10.4.20(postcss@8.4.47) + dotenv: + specifier: ^16.4.5 + version: 16.4.5 esbuild: - specifier: ^0.23.1 - version: 0.23.1 + specifier: ^0.24.0 + version: 0.24.0 esbuild-node-builtin: specifier: ^0.1.1 - version: 0.1.1(esbuild@0.23.1)(rollup@4.20.0) + version: 0.1.1(esbuild@0.24.0)(rollup@4.20.0) esbuild-plugin-copy: specifier: ^2.1.1 - version: 2.1.1(esbuild@0.23.1) + version: 2.1.1(esbuild@0.24.0) esbuild-style-plugin: specifier: ^1.6.3 version: 1.6.3 eslint: - specifier: ^8.57.0 - version: 8.57.0 + specifier: ^8.57.1 + version: 8.57.1 eslint-plugin-html: - specifier: ^8.1.1 - version: 8.1.1 + specifier: ^8.1.2 + version: 8.1.2 eslint-plugin-jest: - specifier: ^28.8.0 - version: 28.8.0(@typescript-eslint/eslint-plugin@8.2.0(@typescript-eslint/parser@8.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(jest@29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)))(typescript@5.5.4) + specifier: ^28.8.3 + version: 28.8.3(@typescript-eslint/eslint-plugin@8.7.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(jest@29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)))(typescript@5.6.2) eslint-plugin-jsx-a11y: - specifier: ^6.9.0 - version: 6.9.0(eslint@8.57.0) + specifier: ^6.10.0 + version: 6.10.0(eslint@8.57.1) eslint-plugin-n: - specifier: ^17.10.2 - version: 17.10.2(eslint@8.57.0) + specifier: ^17.10.3 + version: 17.10.3(eslint@8.57.1) eslint-plugin-react: - specifier: ^7.35.0 - version: 7.35.0(eslint@8.57.0) + specifier: ^7.36.1 + version: 7.36.1(eslint@8.57.1) eslint-plugin-react-hooks: specifier: ^4.6.2 - version: 4.6.2(eslint@8.57.0) + version: 4.6.2(eslint@8.57.1) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + version: 29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) jest-chrome: specifier: ^0.8.0 - version: 0.8.0(jest@29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4))) + version: 0.8.0(jest@29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2))) jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0 @@ -167,29 +176,29 @@ importers: specifier: ^2.0.0 version: 2.0.0 postcss: - specifier: ^8.4.41 - version: 8.4.41 + specifier: ^8.4.47 + version: 8.4.47 prettier: specifier: ^3.3.3 version: 3.3.3 prettier-plugin-tailwindcss: - specifier: ^0.6.6 - version: 0.6.6(prettier@3.3.3) + specifier: ^0.6.8 + version: 0.6.8(prettier@3.3.3) sade: specifier: ^1.8.1 version: 1.8.1 tailwindcss: - specifier: ^3.4.10 - version: 3.4.10(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + specifier: ^3.4.13 + version: 3.4.13(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) ts-jest: - specifier: ^29.2.4 - version: 29.2.4(@babel/core@7.24.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.0))(esbuild@0.23.1)(jest@29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)))(typescript@5.5.4) + specifier: ^29.2.5 + version: 29.2.5(@babel/core@7.24.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.0))(esbuild@0.24.0)(jest@29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)))(typescript@5.6.2) tsx: - specifier: ^4.17.0 - version: 4.17.0 + specifier: ^4.19.1 + version: 4.19.1 typescript: - specifier: ^5.5.4 - version: 5.5.4 + specifier: ^5.6.2 + version: 5.6.2 packages: @@ -411,154 +420,294 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.24.0': + resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.23.1': resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.24.0': + resolution: {integrity: sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.23.1': resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.24.0': + resolution: {integrity: sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.23.1': resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.24.0': + resolution: {integrity: sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.23.1': resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.24.0': + resolution: {integrity: sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.23.1': resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.24.0': + resolution: {integrity: sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.23.1': resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.24.0': + resolution: {integrity: sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.23.1': resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.24.0': + resolution: {integrity: sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.23.1': resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.24.0': + resolution: {integrity: sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.23.1': resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.24.0': + resolution: {integrity: sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.23.1': resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.24.0': + resolution: {integrity: sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.23.1': resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.24.0': + resolution: {integrity: sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.23.1': resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.24.0': + resolution: {integrity: sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.23.1': resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.24.0': + resolution: {integrity: sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.23.1': resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.24.0': + resolution: {integrity: sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.23.1': resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.24.0': + resolution: {integrity: sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.23.1': resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.24.0': + resolution: {integrity: sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-x64@0.23.1': resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.24.0': + resolution: {integrity: sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.23.1': resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.24.0': + resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.23.1': resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.24.0': + resolution: {integrity: sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/sunos-x64@0.23.1': resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.24.0': + resolution: {integrity: sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.23.1': resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.24.0': + resolution: {integrity: sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.23.1': resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.24.0': + resolution: {integrity: sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.23.1': resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.24.0': + resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.4.0': resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.10.0': - resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint-community/regexpp@4.11.0': resolution: {integrity: sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -567,16 +716,16 @@ packages: resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@eslint/js@8.57.0': - resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} '@fastify/busboy@2.1.1': resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} - '@humanwhocodes/config-array@0.11.14': - resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} deprecated: Use @eslint/config-array instead @@ -584,8 +733,8 @@ packages: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/object-schema@2.0.2': - resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead '@interledger/http-signature-utils@2.0.2': @@ -715,9 +864,9 @@ packages: '@noble/ed25519@2.1.0': resolution: {integrity: sha512-KM4qTyXPinyCgMzeYJH/UudpdL+paJXtY3CHtHYZQtBkS8MZoPr4rOikZllIutJe0d06QDQKisyn02gxZ8TcQA==} - '@noble/hashes@1.4.0': - resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} - engines: {node: '>= 16'} + '@noble/hashes@1.5.0': + resolution: {integrity: sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==} + engines: {node: ^14.21.3 || >=16} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -795,8 +944,13 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@remix-run/router@1.19.1': - resolution: {integrity: sha512-S45oynt/WH19bHbIXjtli6QmwNYvaz+vtnubvNpNDvUOoA/OWh6j1OikIP3G+v5GHdxyC6EXoChG3HgYGEUfcg==} + '@playwright/test@1.47.2': + resolution: {integrity: sha512-jTXRsoSPONAs8Za9QEQdyjFn+0ZQFjCiIztAIF6bi1HqhBzG9Ma7g1WotyiGqFSBRZjIEqMdT8RUlbk1QVhzCQ==} + engines: {node: '>=18'} + hasBin: true + + '@remix-run/router@1.19.2': + resolution: {integrity: sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==} engines: {node: '>=14.0.0'} '@rollup/plugin-inject@5.0.5': @@ -906,21 +1060,21 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - '@tailwindcss/forms@0.5.7': - resolution: {integrity: sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==} + '@tailwindcss/forms@0.5.9': + resolution: {integrity: sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==} peerDependencies: - tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20' '@testing-library/dom@10.1.0': resolution: {integrity: sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==} engines: {node: '>=18'} - '@testing-library/jest-dom@6.4.8': - resolution: {integrity: sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==} + '@testing-library/jest-dom@6.5.0': + resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@testing-library/react@16.0.0': - resolution: {integrity: sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ==} + '@testing-library/react@16.0.1': + resolution: {integrity: sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg==} engines: {node: '>=18'} peerDependencies: '@testing-library/dom': ^10.0.0 @@ -971,6 +1125,9 @@ packages: '@types/chrome@0.0.114': resolution: {integrity: sha512-i7qRr74IrxHtbnrZSKUuP5Uvd5EOKwlwJq/yp7+yTPihOXnPhNQO4Z5bqb1XTnrjdbUKEJicaVVbhcgtRijmLA==} + '@types/chrome@0.0.272': + resolution: {integrity: sha512-9cxDmmgyhXV8gsZvlRjqaDizNjIjbV0spsR0fIEaQUoHtbl9D8VkTOLyONgiBKK+guR38x5eMO3E3avUYOXwcQ==} + '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} @@ -998,8 +1155,8 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - '@types/jest@29.5.12': - resolution: {integrity: sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==} + '@types/jest@29.5.13': + resolution: {integrity: sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==} '@types/jsdom@20.0.1': resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} @@ -1016,8 +1173,8 @@ packages: '@types/lodash@4.17.1': resolution: {integrity: sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q==} - '@types/node@20.16.1': - resolution: {integrity: sha512-zJDo7wEadFtSyNz5QITDfRcrhqDvQI1xQNQ0VoizPjM/dVAODqqIUWbJPkvsxmTI0MYRGRikcdjMPhOssnPejQ==} + '@types/node@20.16.9': + resolution: {integrity: sha512-rkvIVJxsOfBejxK7I0FO5sa2WxFmJCzoDwcd88+fq/CUfynNywTo/1/T6hyFz22CyztsnLS9nVlHOnTI36RH5w==} '@types/prop-types@15.7.11': resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} @@ -1031,8 +1188,8 @@ packages: '@types/react-router@5.1.20': resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} - '@types/react@18.3.4': - resolution: {integrity: sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==} + '@types/react@18.3.9': + resolution: {integrity: sha512-+BpAVyTpJkNWWSSnaLBk6ePpHLOGJKnEQNbINNovPWzvEUyAe3e+/d494QdEh71RekM/qV7lw6jzf1HGrJyAtQ==} '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} @@ -1050,8 +1207,8 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} - '@types/webextension-polyfill@0.12.0': - resolution: {integrity: sha512-0d1V0jw5wswj11ijrPoUUP+kV2pHCNKDDgQSzNdD1PK5i3vQ0eHiA1xzT5mFwaqdwatrXdgkxgC4BzCQ6C9eUw==} + '@types/webextension-polyfill@0.12.1': + resolution: {integrity: sha512-xPTFWwQ8BxPevPF2IKsf4hpZNss4LxaOLZXypQH4E63BDLmcwX/RMGdI4tB4VO4Nb6xDBH3F/p4gz4wvof1o9w==} '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1059,8 +1216,8 @@ packages: '@types/yargs@17.0.32': resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==} - '@typescript-eslint/eslint-plugin@8.2.0': - resolution: {integrity: sha512-02tJIs655em7fvt9gps/+4k4OsKULYGtLBPJfOsmOq1+3cdClYiF0+d6mHu6qDnTcg88wJBkcPLpQhq7FyDz0A==} + '@typescript-eslint/eslint-plugin@8.7.0': + resolution: {integrity: sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 @@ -1070,8 +1227,8 @@ packages: typescript: optional: true - '@typescript-eslint/parser@8.2.0': - resolution: {integrity: sha512-j3Di+o0lHgPrb7FxL3fdEy6LJ/j2NE8u+AP/5cQ9SKb+JLH6V6UHDqJ+e0hXBkHP1wn1YDFjYCS9LBQsZDlDEg==} + '@typescript-eslint/parser@8.7.0': + resolution: {integrity: sha512-lN0btVpj2unxHlNYLI//BQ7nzbMJYBVQX5+pbNXvGYazdlgYonMn4AhhHifQ+J4fGRYA/m1DjaQjx+fDetqBOQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -1080,16 +1237,16 @@ packages: typescript: optional: true - '@typescript-eslint/scope-manager@7.16.0': - resolution: {integrity: sha512-8gVv3kW6n01Q6TrI1cmTZ9YMFi3ucDT7i7aI5lEikk2ebk1AEjrwX8MDTdaX5D7fPXMBLvnsaa0IFTAu+jcfOw==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/scope-manager@8.5.0': + resolution: {integrity: sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.2.0': - resolution: {integrity: sha512-OFn80B38yD6WwpoHU2Tz/fTz7CgFqInllBoC3WP+/jLbTb4gGPTy9HBSTsbDWkMdN55XlVU0mMDYAtgvlUspGw==} + '@typescript-eslint/scope-manager@8.7.0': + resolution: {integrity: sha512-87rC0k3ZlDOuz82zzXRtQ7Akv3GKhHs0ti4YcbAJtaomllXoSO8hi7Ix3ccEvCd824dy9aIX+j3d2UMAfCtVpg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/type-utils@8.2.0': - resolution: {integrity: sha512-g1CfXGFMQdT5S+0PSO0fvGXUaiSkl73U1n9LTK5aRAFnPlJ8dLKkXr4AaLFvPedW8lVDoMgLLE3JN98ZZfsj0w==} + '@typescript-eslint/type-utils@8.7.0': + resolution: {integrity: sha512-tl0N0Mj3hMSkEYhLkjREp54OSb/FI6qyCzfiiclvJvOqre6hsZTGSnHtmFLDU8TIM62G7ygEa1bI08lcuRwEnQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '*' @@ -1097,25 +1254,25 @@ packages: typescript: optional: true - '@typescript-eslint/types@7.16.0': - resolution: {integrity: sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/types@8.5.0': + resolution: {integrity: sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.2.0': - resolution: {integrity: sha512-6a9QSK396YqmiBKPkJtxsgZZZVjYQ6wQ/TlI0C65z7vInaETuC6HAHD98AGLC8DyIPqHytvNuS8bBVvNLKyqvQ==} + '@typescript-eslint/types@8.7.0': + resolution: {integrity: sha512-LLt4BLHFwSfASHSF2K29SZ+ZCsbQOM+LuarPjRUuHm+Qd09hSe3GCeaQbcCr+Mik+0QFRmep/FyZBO6fJ64U3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@7.16.0': - resolution: {integrity: sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/typescript-estree@8.5.0': + resolution: {integrity: sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true - '@typescript-eslint/typescript-estree@8.2.0': - resolution: {integrity: sha512-kiG4EDUT4dImplOsbh47B1QnNmXSoUqOjWDvCJw/o8LgfD0yr7k2uy54D5Wm0j4t71Ge1NkynGhpWdS0dEIAUA==} + '@typescript-eslint/typescript-estree@8.7.0': + resolution: {integrity: sha512-MC8nmcGHsmfAKxwnluTQpNqceniT8SteVwd2voYlmiSWGOtjvGXdPl17dYu2797GVscK30Z04WRM28CrKS9WOg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '*' @@ -1123,24 +1280,24 @@ packages: typescript: optional: true - '@typescript-eslint/utils@7.16.0': - resolution: {integrity: sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/utils@8.5.0': + resolution: {integrity: sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 - '@typescript-eslint/utils@8.2.0': - resolution: {integrity: sha512-O46eaYKDlV3TvAVDNcoDzd5N550ckSe8G4phko++OCSC1dYIb9LTc3HDGYdWqWIAT5qDUKphO6sd9RrpIJJPfg==} + '@typescript-eslint/utils@8.7.0': + resolution: {integrity: sha512-ZbdUdwsl2X/s3CiyAu3gOlfQzpbuG3nTWKPoIvAu1pu5r8viiJvv2NPN2AqArL35NCYtw/lrPPfM4gxrMLNLPw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - '@typescript-eslint/visitor-keys@7.16.0': - resolution: {integrity: sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/visitor-keys@8.5.0': + resolution: {integrity: sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.2.0': - resolution: {integrity: sha512-sbgsPMW9yLvS7IhCi8IpuK1oBmtbWUNP+hBdwl/I9nzqVsszGnNGti5r9dUtF5RLivHUFFIdRvLiTsPhzSyJ3Q==} + '@typescript-eslint/visitor-keys@8.7.0': + resolution: {integrity: sha512-b1tx0orFCCh/THWPQa2ZwWzvOeyzzp36vkJYOpVg0u8UVOIsfVrnuC9FqAw9gRKn+rG2VmWQ/zDJZzkxUnj/XQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.2.0': @@ -1309,16 +1466,17 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - awilix@10.0.2: - resolution: {integrity: sha512-hFatb7eZFdtiWjjmGRSm/K/uxZpmcBlM+YoeMB3VpOPXk3xa6+7zctg3LRbUzoimom5bwGrePF0jXReO6b4zNQ==} - engines: {node: '>=14.0.0'} + awilix@11.0.0: + resolution: {integrity: sha512-lnEm2TZu1OUWO1twi/3JrjSYu3RYjiOMiMQgfpwXj6uG4NSDtPSAWwTvqbpV7B8iWsXiC8GoMBKg1YsrUxPJhg==} + engines: {node: '>=16.3.0'} - axe-core@4.9.1: - resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==} + axe-core@4.10.0: + resolution: {integrity: sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==} engines: {node: '>=4'} - axobject-query@3.1.1: - resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} b4a@1.6.6: resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} @@ -1758,6 +1916,10 @@ packages: domutils@3.1.0: resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -1855,6 +2017,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.24.0: + resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.1.2: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} @@ -1891,12 +2058,12 @@ packages: peerDependencies: eslint: '>=8' - eslint-plugin-html@8.1.1: - resolution: {integrity: sha512-6qmlJsc40D2m3Dn9oEH+0PAOkJhxVu0f5sVItqpCE0YWgYnyP4xCjBc3UWTHaJcY9ARkWOLIIuXLq0ndRnQOHw==} + eslint-plugin-html@8.1.2: + resolution: {integrity: sha512-pbRchDV2SmqbCi/Ev/q3aAikzG9BcFe0IjjqjtMn8eTLq71ZUggyJB6CDmuwGAXmYZHrXI12XTfCqvgcnPRqGw==} engines: {node: '>=16.0.0'} - eslint-plugin-jest@28.8.0: - resolution: {integrity: sha512-Tubj1hooFxCl52G4qQu0edzV/+EZzPUeN8p2NnW5uu4fbDs+Yo7+qDVDc4/oG3FbCqEBmu/OC3LSsyiU22oghw==} + eslint-plugin-jest@28.8.3: + resolution: {integrity: sha512-HIQ3t9hASLKm2IhIOqnu+ifw7uLZkIlR7RYNv7fMcEi/p0CIiJmfriStQS2LDkgtY4nyLbIZAD+JL347Yc2ETQ==} engines: {node: ^16.10.0 || ^18.12.0 || >=20.0.0} peerDependencies: '@typescript-eslint/eslint-plugin': ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1908,14 +2075,14 @@ packages: jest: optional: true - eslint-plugin-jsx-a11y@6.9.0: - resolution: {integrity: sha512-nOFOCaJG2pYqORjK19lqPqxMO/JpvdCZdPtNdxY3kvom3jTvkAbOvQvD8wuD0G8BYR0IGAGYDlzqWJOh/ybn2g==} + eslint-plugin-jsx-a11y@6.10.0: + resolution: {integrity: sha512-ySOHvXX8eSN6zz8Bywacm7CvGNhUtdjvqfQDVe6020TUK34Cywkw7m0KsCCk1Qtm9G1FayfTN1/7mMYnYO2Bhg==} engines: {node: '>=4.0'} peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - eslint-plugin-n@17.10.2: - resolution: {integrity: sha512-e+s4eAf5NtJaxPhTNu3qMO0Iz40WANS93w9LQgYcvuljgvDmWi/a3rh+OrNyMHeng6aOWGJO0rCg5lH4zi8yTw==} + eslint-plugin-n@17.10.3: + resolution: {integrity: sha512-ySZBfKe49nQZWR1yFaA0v/GsH6Fgp8ah6XV0WDz6CN8WO0ek4McMzb7A2xnf4DCYV43frjCygvb9f/wx7UUxRw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: '>=8.23.0' @@ -1926,8 +2093,8 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 - eslint-plugin-react@7.35.0: - resolution: {integrity: sha512-v501SSMOWv8gerHkk+IIQBkcGRGrO2nfybfj5pLxuJNFTPxxA3PSryhXTK+9pNbtkggheDdsC0E9Q8CuPk6JKA==} + eslint-plugin-react@7.36.1: + resolution: {integrity: sha512-/qwbqNXZoq+VP30s1d4Nc1C5GTxjJQjk4Jzs4Wq2qzxFM7dSmuG2UkIjg2USMLh3A/aVcUNrK7v0J5U1XEGGwA==} engines: {node: '>=4'} peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 @@ -1940,8 +2107,8 @@ packages: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint@8.57.0: - resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} hasBin: true @@ -2054,8 +2221,8 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - framer-motion@11.3.28: - resolution: {integrity: sha512-dqhoawipEAjqdv32zbv72sOMJZjol7dROWn7t/FOq23WXJ40O4OUybgnO2ldnuS+3YquSn8xO/KKRavZ+TBVOQ==} + framer-motion@11.7.0: + resolution: {integrity: sha512-m+1E3mMzDIQ5DsVghMvXyC+jSkZSm5RHBLA2gHa/LczcXwW6JbQK4Uz48LsuCTGV8bZFVUezcauHj3M33tY/5w==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 @@ -2079,6 +2246,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2124,8 +2296,8 @@ packages: get-tsconfig@4.7.5: resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==} - github-script@https://codeload.github.com/actions/github-script/tar.gz/35b1cdd1b2c1fc704b1cd9758d10f67e833fcb02: - resolution: {tarball: https://codeload.github.com/actions/github-script/tar.gz/35b1cdd1b2c1fc704b1cd9758d10f67e833fcb02} + github-script@https://codeload.github.com/actions/github-script/tar.gz/660ec11d825b714d112a6bb9727086bc2cc500b2: + resolution: {tarball: https://codeload.github.com/actions/github-script/tar.gz/660ec11d825b714d112a6bb9727086bc2cc500b2} version: 7.0.1 engines: {node: '>=20.0.0 <21.0.0'} @@ -2761,8 +2933,8 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - loglevel@1.9.1: - resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==} + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} loose-envify@1.4.0: @@ -3063,6 +3235,9 @@ packages: picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + picocolors@1.1.0: + resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -3089,6 +3264,16 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + playwright-core@1.47.2: + resolution: {integrity: sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.47.2: + resolution: {integrity: sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} @@ -3159,16 +3344,16 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.41: - resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==} + postcss@8.4.47: + resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-plugin-tailwindcss@0.6.6: - resolution: {integrity: sha512-OPva5S7WAsPLEsOuOWXATi13QrCKACCiIonFgIR6V4lYv4QLp++UXVhZSzRbZxXGimkQtQT86CC6fQqTOybGng==} + prettier-plugin-tailwindcss@0.6.8: + resolution: {integrity: sha512-dGu3kdm7SXPkiW4nzeWKCl3uoImdd5CTZEJGxyypEPL37Wj0HT2pLqjrvSei1nTeuQfO4PUfjeW5cTUNRLZ4sA==} engines: {node: '>=14.21.3'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' @@ -3284,8 +3469,8 @@ packages: peerDependencies: react: ^18.3.1 - react-hook-form@7.52.2: - resolution: {integrity: sha512-pqfPEbERnxxiNMPd0bzmt1tuaPcVccywFDpyk2uV5xCIBphHV5T8SVnX9/o3kplPE1zzKt77+YIoq+EMwJp56A==} + react-hook-form@7.53.0: + resolution: {integrity: sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 @@ -3299,15 +3484,15 @@ packages: react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} - react-router-dom@6.26.1: - resolution: {integrity: sha512-veut7m41S1fLql4pLhxeSW3jlqs+4MtjRLj0xvuCEXsxusJCbs6I8yn9BxzzDX2XDgafrccY6hwjmd/bL54tFw==} + react-router-dom@6.26.2: + resolution: {integrity: sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' react-dom: '>=16.8' - react-router@6.26.1: - resolution: {integrity: sha512-kIwJveZNwp7teQRI5QmwWo39A5bXRyqpH0COKKmPnyD2vBvDwgFXSqDUYtt1h+FEyfnE8eXr7oe0MxRzVwCcvQ==} + react-router@6.26.2: + resolution: {integrity: sha512-tvN1iuT03kHgOFnLPfLJ8V95eijteveqdOSk+srqfePtQvqCExB8eHOYnlilbOcyJyKnYkr1vJvf7YqotAJu1A==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' @@ -3454,6 +3639,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + set-function-length@1.2.1: resolution: {integrity: sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==} engines: {node: '>= 0.4'} @@ -3502,6 +3692,10 @@ packages: resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} @@ -3625,8 +3819,8 @@ packages: tailwind-merge@2.5.2: resolution: {integrity: sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==} - tailwindcss@3.4.10: - resolution: {integrity: sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==} + tailwindcss@3.4.13: + resolution: {integrity: sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==} engines: {node: '>=14.0.0'} hasBin: true @@ -3689,8 +3883,8 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-jest@29.2.4: - resolution: {integrity: sha512-3d6tgDyhCI29HlpwIq87sNuI+3Q6GLTTCeYRHCs7vDz+/3GCMwEtV9jezLyl4ZtnBgx00I7hm8PCP8cTksMGrw==} + ts-jest@29.2.5: + resolution: {integrity: sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -3737,8 +3931,8 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - tsx@4.17.0: - resolution: {integrity: sha512-eN4mnDA5UMKDt4YZixo9tBioibaMBpoxBkD+rIPAjVmYERSG0/dWEY1CEFuV89CgASlKL499q8AhmkMnnjtOJg==} + tsx@4.19.1: + resolution: {integrity: sha512-0flMz1lh74BR4wOvBjuh9olbnwqCPc35OOlfyzHba0Dc+QNUeWX/Gq2YTbnwcWPO3BMd8fkzRVrHcsR+a7z7rA==} engines: {node: '>=18.0.0'} hasBin: true @@ -3782,8 +3976,8 @@ packages: resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} engines: {node: '>= 0.4'} - typescript@5.5.4: - resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} + typescript@5.6.2: + resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} engines: {node: '>=14.17'} hasBin: true @@ -4226,82 +4420,152 @@ snapshots: '@esbuild/aix-ppc64@0.23.1': optional: true + '@esbuild/aix-ppc64@0.24.0': + optional: true + '@esbuild/android-arm64@0.23.1': optional: true + '@esbuild/android-arm64@0.24.0': + optional: true + '@esbuild/android-arm@0.23.1': optional: true + '@esbuild/android-arm@0.24.0': + optional: true + '@esbuild/android-x64@0.23.1': optional: true + '@esbuild/android-x64@0.24.0': + optional: true + '@esbuild/darwin-arm64@0.23.1': optional: true + '@esbuild/darwin-arm64@0.24.0': + optional: true + '@esbuild/darwin-x64@0.23.1': optional: true + '@esbuild/darwin-x64@0.24.0': + optional: true + '@esbuild/freebsd-arm64@0.23.1': optional: true + '@esbuild/freebsd-arm64@0.24.0': + optional: true + '@esbuild/freebsd-x64@0.23.1': optional: true + '@esbuild/freebsd-x64@0.24.0': + optional: true + '@esbuild/linux-arm64@0.23.1': optional: true + '@esbuild/linux-arm64@0.24.0': + optional: true + '@esbuild/linux-arm@0.23.1': optional: true + '@esbuild/linux-arm@0.24.0': + optional: true + '@esbuild/linux-ia32@0.23.1': optional: true + '@esbuild/linux-ia32@0.24.0': + optional: true + '@esbuild/linux-loong64@0.23.1': optional: true + '@esbuild/linux-loong64@0.24.0': + optional: true + '@esbuild/linux-mips64el@0.23.1': optional: true + '@esbuild/linux-mips64el@0.24.0': + optional: true + '@esbuild/linux-ppc64@0.23.1': optional: true + '@esbuild/linux-ppc64@0.24.0': + optional: true + '@esbuild/linux-riscv64@0.23.1': optional: true + '@esbuild/linux-riscv64@0.24.0': + optional: true + '@esbuild/linux-s390x@0.23.1': optional: true + '@esbuild/linux-s390x@0.24.0': + optional: true + '@esbuild/linux-x64@0.23.1': optional: true + '@esbuild/linux-x64@0.24.0': + optional: true + '@esbuild/netbsd-x64@0.23.1': optional: true + '@esbuild/netbsd-x64@0.24.0': + optional: true + '@esbuild/openbsd-arm64@0.23.1': optional: true + '@esbuild/openbsd-arm64@0.24.0': + optional: true + '@esbuild/openbsd-x64@0.23.1': optional: true + '@esbuild/openbsd-x64@0.24.0': + optional: true + '@esbuild/sunos-x64@0.23.1': optional: true + '@esbuild/sunos-x64@0.24.0': + optional: true + '@esbuild/win32-arm64@0.23.1': optional: true + '@esbuild/win32-arm64@0.24.0': + optional: true + '@esbuild/win32-ia32@0.23.1': optional: true + '@esbuild/win32-ia32@0.24.0': + optional: true + '@esbuild/win32-x64@0.23.1': optional: true - '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': + '@esbuild/win32-x64@0.24.0': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': dependencies: - eslint: 8.57.0 + eslint: 8.57.1 eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.10.0': {} - '@eslint-community/regexpp@4.11.0': {} '@eslint/eslintrc@2.1.4': @@ -4318,13 +4582,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@8.57.0': {} + '@eslint/js@8.57.1': {} '@fastify/busboy@2.1.1': {} - '@humanwhocodes/config-array@0.11.14': + '@humanwhocodes/config-array@0.13.0': dependencies: - '@humanwhocodes/object-schema': 2.0.2 + '@humanwhocodes/object-schema': 2.0.3 debug: 4.3.4 minimatch: 3.1.2 transitivePeerDependencies: @@ -4332,7 +4596,7 @@ snapshots: '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/object-schema@2.0.2': {} + '@humanwhocodes/object-schema@2.0.3': {} '@interledger/http-signature-utils@2.0.2': dependencies: @@ -4389,27 +4653,27 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.16.9 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4))': + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.16.9 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + jest-config: 29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -4434,7 +4698,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.16.9 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -4452,7 +4716,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.16.1 + '@types/node': 20.16.9 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -4474,7 +4738,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.24 - '@types/node': 20.16.1 + '@types/node': 20.16.9 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -4544,14 +4808,14 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.16.1 + '@types/node': 20.16.9 '@types/yargs': 17.0.32 chalk: 4.1.2 - '@jgoz/esbuild-plugin-typecheck@4.0.1(esbuild@0.23.1)(typescript@5.5.4)': + '@jgoz/esbuild-plugin-typecheck@4.0.1(esbuild@0.24.0)(typescript@5.6.2)': dependencies: - esbuild: 0.23.1 - typescript: 5.5.4 + esbuild: 0.24.0 + typescript: 5.6.2 '@jridgewell/gen-mapping@0.3.5': dependencies: @@ -4582,7 +4846,7 @@ snapshots: '@noble/ed25519@2.1.0': {} - '@noble/hashes@1.4.0': {} + '@noble/hashes@1.5.0': {} '@nodelib/fs.scandir@2.1.5': dependencies: @@ -4668,7 +4932,11 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@remix-run/router@1.19.1': {} + '@playwright/test@1.47.2': + dependencies: + playwright: 1.47.2 + + '@remix-run/router@1.19.2': {} '@rollup/plugin-inject@5.0.5(rollup@4.20.0)': dependencies: @@ -4744,10 +5012,10 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@tailwindcss/forms@0.5.7(tailwindcss@3.4.10(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)))': + '@tailwindcss/forms@0.5.9(tailwindcss@3.4.13(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)))': dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.4.10(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + tailwindcss: 3.4.13(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) '@testing-library/dom@10.1.0': dependencies: @@ -4760,10 +5028,9 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.4.8': + '@testing-library/jest-dom@6.5.0': dependencies: '@adobe/css-tools': 4.4.0 - '@babel/runtime': 7.24.4 aria-query: 5.3.0 chalk: 3.0.0 css.escape: 1.5.1 @@ -4771,14 +5038,14 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/react@16.0.0(@testing-library/dom@10.1.0)(@types/react-dom@18.3.0)(@types/react@18.3.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@testing-library/react@16.0.1(@testing-library/dom@10.1.0)(@types/react-dom@18.3.0)(@types/react@18.3.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.24.4 '@testing-library/dom': 10.1.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - '@types/react': 18.3.4 + '@types/react': 18.3.9 '@types/react-dom': 18.3.0 '@tootallnate/once@2.0.0': {} @@ -4827,6 +5094,11 @@ snapshots: '@types/filesystem': 0.0.35 '@types/har-format': 1.2.15 + '@types/chrome@0.0.272': + dependencies: + '@types/filesystem': 0.0.35 + '@types/har-format': 1.2.15 + '@types/estree@1.0.5': {} '@types/filesystem@0.0.35': @@ -4837,7 +5109,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 20.16.1 + '@types/node': 20.16.9 '@types/har-format@1.2.15': {} @@ -4853,14 +5125,14 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 - '@types/jest@29.5.12': + '@types/jest@29.5.13': dependencies: expect: 29.7.0 pretty-format: 29.7.0 '@types/jsdom@20.0.1': dependencies: - '@types/node': 20.16.1 + '@types/node': 20.16.9 '@types/tough-cookie': 4.0.5 parse5: 7.1.2 @@ -4874,7 +5146,7 @@ snapshots: '@types/lodash@4.17.1': {} - '@types/node@20.16.1': + '@types/node@20.16.9': dependencies: undici-types: 6.19.8 @@ -4882,27 +5154,27 @@ snapshots: '@types/react-dom@18.3.0': dependencies: - '@types/react': 18.3.4 + '@types/react': 18.3.9 '@types/react-router-dom@5.3.3': dependencies: '@types/history': 4.7.11 - '@types/react': 18.3.4 + '@types/react': 18.3.9 '@types/react-router': 5.1.20 '@types/react-router@5.1.20': dependencies: '@types/history': 4.7.11 - '@types/react': 18.3.4 + '@types/react': 18.3.9 - '@types/react@18.3.4': + '@types/react@18.3.9': dependencies: '@types/prop-types': 15.7.11 csstype: 3.1.3 '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 20.16.1 + '@types/node': 20.16.9 '@types/sass@1.45.0': dependencies: @@ -4912,11 +5184,11 @@ snapshots: '@types/stylus@0.48.42': dependencies: - '@types/node': 20.16.1 + '@types/node': 20.16.9 '@types/tough-cookie@4.0.5': {} - '@types/webextension-polyfill@0.12.0': {} + '@types/webextension-polyfill@0.12.1': {} '@types/yargs-parser@21.0.3': {} @@ -4924,123 +5196,123 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.2.0(@typescript-eslint/parser@8.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4)': + '@typescript-eslint/eslint-plugin@8.7.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2)': dependencies: - '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 8.2.0(eslint@8.57.0)(typescript@5.5.4) - '@typescript-eslint/scope-manager': 8.2.0 - '@typescript-eslint/type-utils': 8.2.0(eslint@8.57.0)(typescript@5.5.4) - '@typescript-eslint/utils': 8.2.0(eslint@8.57.0)(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 8.2.0 - eslint: 8.57.0 + '@eslint-community/regexpp': 4.11.0 + '@typescript-eslint/parser': 8.7.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/scope-manager': 8.7.0 + '@typescript-eslint/type-utils': 8.7.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/utils': 8.7.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/visitor-keys': 8.7.0 + eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.3.1 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.5.4) + ts-api-utils: 1.3.0(typescript@5.6.2) optionalDependencies: - typescript: 5.5.4 + typescript: 5.6.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.2.0(eslint@8.57.0)(typescript@5.5.4)': + '@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2)': dependencies: - '@typescript-eslint/scope-manager': 8.2.0 - '@typescript-eslint/types': 8.2.0 - '@typescript-eslint/typescript-estree': 8.2.0(typescript@5.5.4) - '@typescript-eslint/visitor-keys': 8.2.0 + '@typescript-eslint/scope-manager': 8.7.0 + '@typescript-eslint/types': 8.7.0 + '@typescript-eslint/typescript-estree': 8.7.0(typescript@5.6.2) + '@typescript-eslint/visitor-keys': 8.7.0 debug: 4.3.4 - eslint: 8.57.0 + eslint: 8.57.1 optionalDependencies: - typescript: 5.5.4 + typescript: 5.6.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@7.16.0': + '@typescript-eslint/scope-manager@8.5.0': dependencies: - '@typescript-eslint/types': 7.16.0 - '@typescript-eslint/visitor-keys': 7.16.0 + '@typescript-eslint/types': 8.5.0 + '@typescript-eslint/visitor-keys': 8.5.0 - '@typescript-eslint/scope-manager@8.2.0': + '@typescript-eslint/scope-manager@8.7.0': dependencies: - '@typescript-eslint/types': 8.2.0 - '@typescript-eslint/visitor-keys': 8.2.0 + '@typescript-eslint/types': 8.7.0 + '@typescript-eslint/visitor-keys': 8.7.0 - '@typescript-eslint/type-utils@8.2.0(eslint@8.57.0)(typescript@5.5.4)': + '@typescript-eslint/type-utils@8.7.0(eslint@8.57.1)(typescript@5.6.2)': dependencies: - '@typescript-eslint/typescript-estree': 8.2.0(typescript@5.5.4) - '@typescript-eslint/utils': 8.2.0(eslint@8.57.0)(typescript@5.5.4) + '@typescript-eslint/typescript-estree': 8.7.0(typescript@5.6.2) + '@typescript-eslint/utils': 8.7.0(eslint@8.57.1)(typescript@5.6.2) debug: 4.3.4 - ts-api-utils: 1.3.0(typescript@5.5.4) + ts-api-utils: 1.3.0(typescript@5.6.2) optionalDependencies: - typescript: 5.5.4 + typescript: 5.6.2 transitivePeerDependencies: - eslint - supports-color - '@typescript-eslint/types@7.16.0': {} + '@typescript-eslint/types@8.5.0': {} - '@typescript-eslint/types@8.2.0': {} + '@typescript-eslint/types@8.7.0': {} - '@typescript-eslint/typescript-estree@7.16.0(typescript@5.5.4)': + '@typescript-eslint/typescript-estree@8.5.0(typescript@5.6.2)': dependencies: - '@typescript-eslint/types': 7.16.0 - '@typescript-eslint/visitor-keys': 7.16.0 + '@typescript-eslint/types': 8.5.0 + '@typescript-eslint/visitor-keys': 8.5.0 debug: 4.3.4 - globby: 11.1.0 + fast-glob: 3.3.2 is-glob: 4.0.3 - minimatch: 9.0.4 - semver: 7.6.0 - ts-api-utils: 1.3.0(typescript@5.5.4) + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 1.3.0(typescript@5.6.2) optionalDependencies: - typescript: 5.5.4 + typescript: 5.6.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.2.0(typescript@5.5.4)': + '@typescript-eslint/typescript-estree@8.7.0(typescript@5.6.2)': dependencies: - '@typescript-eslint/types': 8.2.0 - '@typescript-eslint/visitor-keys': 8.2.0 + '@typescript-eslint/types': 8.7.0 + '@typescript-eslint/visitor-keys': 8.7.0 debug: 4.3.4 - globby: 11.1.0 + fast-glob: 3.3.2 is-glob: 4.0.3 - minimatch: 9.0.4 - semver: 7.6.0 - ts-api-utils: 1.3.0(typescript@5.5.4) + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 1.3.0(typescript@5.6.2) optionalDependencies: - typescript: 5.5.4 + typescript: 5.6.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.16.0(eslint@8.57.0)(typescript@5.5.4)': + '@typescript-eslint/utils@8.5.0(eslint@8.57.1)(typescript@5.6.2)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@typescript-eslint/scope-manager': 7.16.0 - '@typescript-eslint/types': 7.16.0 - '@typescript-eslint/typescript-estree': 7.16.0(typescript@5.5.4) - eslint: 8.57.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.5.0 + '@typescript-eslint/types': 8.5.0 + '@typescript-eslint/typescript-estree': 8.5.0(typescript@5.6.2) + eslint: 8.57.1 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@8.2.0(eslint@8.57.0)(typescript@5.5.4)': + '@typescript-eslint/utils@8.7.0(eslint@8.57.1)(typescript@5.6.2)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@typescript-eslint/scope-manager': 8.2.0 - '@typescript-eslint/types': 8.2.0 - '@typescript-eslint/typescript-estree': 8.2.0(typescript@5.5.4) - eslint: 8.57.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.7.0 + '@typescript-eslint/types': 8.7.0 + '@typescript-eslint/typescript-estree': 8.7.0(typescript@5.6.2) + eslint: 8.57.1 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/visitor-keys@7.16.0': + '@typescript-eslint/visitor-keys@8.5.0': dependencies: - '@typescript-eslint/types': 7.16.0 + '@typescript-eslint/types': 8.5.0 eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@8.2.0': + '@typescript-eslint/visitor-keys@8.7.0': dependencies: - '@typescript-eslint/types': 8.2.0 + '@typescript-eslint/types': 8.7.0 eslint-visitor-keys: 3.4.3 '@ungap/structured-clone@1.2.0': {} @@ -5227,30 +5499,28 @@ snapshots: atomic-sleep@1.0.0: {} - autoprefixer@10.4.20(postcss@8.4.41): + autoprefixer@10.4.20(postcss@8.4.47): dependencies: browserslist: 4.23.3 caniuse-lite: 1.0.30001651 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.0.1 - postcss: 8.4.41 + postcss: 8.4.47 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 - awilix@10.0.2: + awilix@11.0.0: dependencies: camel-case: 4.1.2 fast-glob: 3.3.2 - axe-core@4.9.1: {} + axe-core@4.10.0: {} - axobject-query@3.1.1: - dependencies: - deep-equal: 2.2.3 + axobject-query@4.1.0: {} b4a@1.6.6: {} @@ -5561,13 +5831,13 @@ snapshots: safe-buffer: 5.2.1 sha.js: 2.4.11 - create-jest@29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)): + create-jest@29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + jest-config: 29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -5762,6 +6032,8 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dotenv@16.4.5: {} + eastasianwidth@0.2.0: {} ee-first@1.1.1: {} @@ -5907,20 +6179,20 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 - esbuild-node-builtin@0.1.1(esbuild@0.23.1)(rollup@4.20.0): + esbuild-node-builtin@0.1.1(esbuild@0.24.0)(rollup@4.20.0): dependencies: debug: 4.3.4 - esbuild: 0.23.1 + esbuild: 0.24.0 rollup-plugin-polyfill-node: 0.12.0(rollup@4.20.0) transitivePeerDependencies: - rollup - supports-color - esbuild-plugin-copy@2.1.1(esbuild@0.23.1): + esbuild-plugin-copy@2.1.1(esbuild@0.24.0): dependencies: chalk: 4.1.2 chokidar: 3.6.0 - esbuild: 0.23.1 + esbuild: 0.24.0 fs-extra: 10.1.0 globby: 11.1.0 @@ -5930,8 +6202,8 @@ snapshots: '@types/sass': 1.45.0 '@types/stylus': 0.48.42 glob: 10.3.10 - postcss: 8.4.41 - postcss-modules: 6.0.0(postcss@8.4.41) + postcss: 8.4.47 + postcss-modules: 6.0.0(postcss@8.4.47) esbuild@0.23.1: optionalDependencies: @@ -5960,6 +6232,33 @@ snapshots: '@esbuild/win32-ia32': 0.23.1 '@esbuild/win32-x64': 0.23.1 + esbuild@0.24.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.0 + '@esbuild/android-arm': 0.24.0 + '@esbuild/android-arm64': 0.24.0 + '@esbuild/android-x64': 0.24.0 + '@esbuild/darwin-arm64': 0.24.0 + '@esbuild/darwin-x64': 0.24.0 + '@esbuild/freebsd-arm64': 0.24.0 + '@esbuild/freebsd-x64': 0.24.0 + '@esbuild/linux-arm': 0.24.0 + '@esbuild/linux-arm64': 0.24.0 + '@esbuild/linux-ia32': 0.24.0 + '@esbuild/linux-loong64': 0.24.0 + '@esbuild/linux-mips64el': 0.24.0 + '@esbuild/linux-ppc64': 0.24.0 + '@esbuild/linux-riscv64': 0.24.0 + '@esbuild/linux-s390x': 0.24.0 + '@esbuild/linux-x64': 0.24.0 + '@esbuild/netbsd-x64': 0.24.0 + '@esbuild/openbsd-arm64': 0.24.0 + '@esbuild/openbsd-x64': 0.24.0 + '@esbuild/sunos-x64': 0.24.0 + '@esbuild/win32-arm64': 0.24.0 + '@esbuild/win32-ia32': 0.24.0 + '@esbuild/win32-x64': 0.24.0 + escalade@3.1.2: {} escape-html@1.0.3: {} @@ -5978,45 +6277,45 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-compat-utils@0.5.1(eslint@8.57.0): + eslint-compat-utils@0.5.1(eslint@8.57.1): dependencies: - eslint: 8.57.0 - semver: 7.6.0 + eslint: 8.57.1 + semver: 7.6.3 - eslint-plugin-es-x@7.8.0(eslint@8.57.0): + eslint-plugin-es-x@7.8.0(eslint@8.57.1): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@eslint-community/regexpp': 4.11.0 - eslint: 8.57.0 - eslint-compat-utils: 0.5.1(eslint@8.57.0) + eslint: 8.57.1 + eslint-compat-utils: 0.5.1(eslint@8.57.1) - eslint-plugin-html@8.1.1: + eslint-plugin-html@8.1.2: dependencies: htmlparser2: 9.1.0 - eslint-plugin-jest@28.8.0(@typescript-eslint/eslint-plugin@8.2.0(@typescript-eslint/parser@8.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(jest@29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)))(typescript@5.5.4): + eslint-plugin-jest@28.8.3(@typescript-eslint/eslint-plugin@8.7.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(jest@29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)))(typescript@5.6.2): dependencies: - '@typescript-eslint/utils': 7.16.0(eslint@8.57.0)(typescript@5.5.4) - eslint: 8.57.0 + '@typescript-eslint/utils': 8.5.0(eslint@8.57.1)(typescript@5.6.2) + eslint: 8.57.1 optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.2.0(@typescript-eslint/parser@8.2.0(eslint@8.57.0)(typescript@5.5.4))(eslint@8.57.0)(typescript@5.5.4) - jest: 29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + '@typescript-eslint/eslint-plugin': 8.7.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.6.2))(eslint@8.57.1)(typescript@5.6.2) + jest: 29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) transitivePeerDependencies: - supports-color - typescript - eslint-plugin-jsx-a11y@6.9.0(eslint@8.57.0): + eslint-plugin-jsx-a11y@6.10.0(eslint@8.57.1): dependencies: aria-query: 5.1.3 array-includes: 3.1.8 array.prototype.flatmap: 1.3.2 ast-types-flow: 0.0.8 - axe-core: 4.9.1 - axobject-query: 3.1.1 + axe-core: 4.10.0 + axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 es-iterator-helpers: 1.0.19 - eslint: 8.57.0 + eslint: 8.57.1 hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -6025,23 +6324,23 @@ snapshots: safe-regex-test: 1.0.3 string.prototype.includes: 2.0.0 - eslint-plugin-n@17.10.2(eslint@8.57.0): + eslint-plugin-n@17.10.3(eslint@8.57.1): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) enhanced-resolve: 5.17.1 - eslint: 8.57.0 - eslint-plugin-es-x: 7.8.0(eslint@8.57.0) + eslint: 8.57.1 + eslint-plugin-es-x: 7.8.0(eslint@8.57.1) get-tsconfig: 4.7.5 globals: 15.9.0 ignore: 5.3.1 minimatch: 9.0.5 - semver: 7.6.0 + semver: 7.6.3 - eslint-plugin-react-hooks@4.6.2(eslint@8.57.0): + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): dependencies: - eslint: 8.57.0 + eslint: 8.57.1 - eslint-plugin-react@7.35.0(eslint@8.57.0): + eslint-plugin-react@7.36.1(eslint@8.57.1): dependencies: array-includes: 3.1.8 array.prototype.findlast: 1.2.5 @@ -6049,7 +6348,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.0.19 - eslint: 8.57.0 + eslint: 8.57.1 estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -6070,13 +6369,13 @@ snapshots: eslint-visitor-keys@3.4.3: {} - eslint@8.57.0: + eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@eslint-community/regexpp': 4.10.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.11.0 '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.0 - '@humanwhocodes/config-array': 0.11.14 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.2.0 @@ -6235,7 +6534,7 @@ snapshots: fraction.js@4.3.7: {} - framer-motion@11.3.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + framer-motion@11.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: tslib: 2.6.2 optionalDependencies: @@ -6252,6 +6551,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -6296,7 +6598,7 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - github-script@https://codeload.github.com/actions/github-script/tar.gz/35b1cdd1b2c1fc704b1cd9758d10f67e833fcb02: + github-script@https://codeload.github.com/actions/github-script/tar.gz/660ec11d825b714d112a6bb9727086bc2cc500b2: dependencies: '@actions/core': 1.10.1 '@actions/exec': 1.1.1 @@ -6306,7 +6608,7 @@ snapshots: '@octokit/core': 5.2.0 '@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.0) '@octokit/plugin-retry': 6.0.1(@octokit/core@5.2.0) - '@types/node': 20.16.1 + '@types/node': 20.16.9 glob-parent@5.1.2: dependencies: @@ -6463,9 +6765,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - icss-utils@5.1.0(postcss@8.4.41): + icss-utils@5.1.0(postcss@8.4.47): dependencies: - postcss: 8.4.41 + postcss: 8.4.47 ignore@5.3.1: {} @@ -6635,7 +6937,7 @@ snapshots: '@babel/parser': 7.24.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.6.0 + semver: 7.6.3 transitivePeerDependencies: - supports-color @@ -6685,10 +6987,10 @@ snapshots: jest-util: 29.7.0 p-limit: 3.1.0 - jest-chrome@0.8.0(jest@29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4))): + jest-chrome@0.8.0(jest@29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2))): dependencies: '@types/chrome': 0.0.114 - jest: 29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + jest: 29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) jest-circus@29.7.0: dependencies: @@ -6696,7 +6998,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.16.9 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.1 @@ -6716,16 +7018,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)): + jest-cli@29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + create-jest: 29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + jest-config: 29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -6735,7 +7037,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)): + jest-config@29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)): dependencies: '@babel/core': 7.24.0 '@jest/test-sequencer': 29.7.0 @@ -6760,8 +7062,8 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 20.16.1 - ts-node: 10.9.2(@types/node@20.16.1)(typescript@5.5.4) + '@types/node': 20.16.9 + ts-node: 10.9.2(@types/node@20.16.9)(typescript@5.6.2) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -6791,7 +7093,7 @@ snapshots: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 '@types/jsdom': 20.0.1 - '@types/node': 20.16.1 + '@types/node': 20.16.9 jest-mock: 29.7.0 jest-util: 29.7.0 jsdom: 20.0.3 @@ -6805,7 +7107,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.16.9 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -6815,7 +7117,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.16.1 + '@types/node': 20.16.9 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -6854,7 +7156,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.16.9 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -6889,7 +7191,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.16.9 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -6917,7 +7219,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.16.9 chalk: 4.1.2 cjs-module-lexer: 1.2.3 collect-v8-coverage: 1.0.2 @@ -6965,7 +7267,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.16.9 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -6984,7 +7286,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.16.1 + '@types/node': 20.16.9 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -6993,17 +7295,17 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 20.16.1 + '@types/node': 20.16.9 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)): + jest@29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + jest-cli: 29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -7175,7 +7477,7 @@ snapshots: lodash@4.17.21: {} - loglevel@1.9.1: {} + loglevel@1.9.2: {} loose-envify@1.4.0: dependencies: @@ -7203,7 +7505,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.6.0 + semver: 7.6.3 make-error@1.3.6: {} @@ -7471,6 +7773,8 @@ snapshots: picocolors@1.0.1: {} + picocolors@1.1.0: {} + picomatch@2.3.1: {} pify@2.3.0: {} @@ -7502,64 +7806,72 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.47.2: {} + + playwright@1.47.2: + dependencies: + playwright-core: 1.47.2 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.0.0: {} - postcss-import@15.1.0(postcss@8.4.41): + postcss-import@15.1.0(postcss@8.4.47): dependencies: - postcss: 8.4.41 + postcss: 8.4.47 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 - postcss-js@4.0.1(postcss@8.4.41): + postcss-js@4.0.1(postcss@8.4.47): dependencies: camelcase-css: 2.0.1 - postcss: 8.4.41 + postcss: 8.4.47 - postcss-load-config@4.0.2(postcss@8.4.41)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)): + postcss-load-config@4.0.2(postcss@8.4.47)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)): dependencies: lilconfig: 3.1.1 yaml: 2.4.0 optionalDependencies: - postcss: 8.4.41 - ts-node: 10.9.2(@types/node@20.16.1)(typescript@5.5.4) + postcss: 8.4.47 + ts-node: 10.9.2(@types/node@20.16.9)(typescript@5.6.2) - postcss-modules-extract-imports@3.1.0(postcss@8.4.41): + postcss-modules-extract-imports@3.1.0(postcss@8.4.47): dependencies: - postcss: 8.4.41 + postcss: 8.4.47 - postcss-modules-local-by-default@4.0.5(postcss@8.4.41): + postcss-modules-local-by-default@4.0.5(postcss@8.4.47): dependencies: - icss-utils: 5.1.0(postcss@8.4.41) - postcss: 8.4.41 + icss-utils: 5.1.0(postcss@8.4.47) + postcss: 8.4.47 postcss-selector-parser: 6.0.15 postcss-value-parser: 4.2.0 - postcss-modules-scope@3.2.0(postcss@8.4.41): + postcss-modules-scope@3.2.0(postcss@8.4.47): dependencies: - postcss: 8.4.41 + postcss: 8.4.47 postcss-selector-parser: 6.0.15 - postcss-modules-values@4.0.0(postcss@8.4.41): + postcss-modules-values@4.0.0(postcss@8.4.47): dependencies: - icss-utils: 5.1.0(postcss@8.4.41) - postcss: 8.4.41 + icss-utils: 5.1.0(postcss@8.4.47) + postcss: 8.4.47 - postcss-modules@6.0.0(postcss@8.4.41): + postcss-modules@6.0.0(postcss@8.4.47): dependencies: generic-names: 4.0.0 - icss-utils: 5.1.0(postcss@8.4.41) + icss-utils: 5.1.0(postcss@8.4.47) lodash.camelcase: 4.3.0 - postcss: 8.4.41 - postcss-modules-extract-imports: 3.1.0(postcss@8.4.41) - postcss-modules-local-by-default: 4.0.5(postcss@8.4.41) - postcss-modules-scope: 3.2.0(postcss@8.4.41) - postcss-modules-values: 4.0.0(postcss@8.4.41) + postcss: 8.4.47 + postcss-modules-extract-imports: 3.1.0(postcss@8.4.47) + postcss-modules-local-by-default: 4.0.5(postcss@8.4.47) + postcss-modules-scope: 3.2.0(postcss@8.4.47) + postcss-modules-values: 4.0.0(postcss@8.4.47) string-hash: 1.1.3 - postcss-nested@6.0.1(postcss@8.4.41): + postcss-nested@6.0.1(postcss@8.4.47): dependencies: - postcss: 8.4.41 + postcss: 8.4.47 postcss-selector-parser: 6.0.15 postcss-selector-parser@6.0.15: @@ -7569,15 +7881,15 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.4.41: + postcss@8.4.47: dependencies: nanoid: 3.3.7 - picocolors: 1.0.1 - source-map-js: 1.2.0 + picocolors: 1.1.0 + source-map-js: 1.2.1 prelude-ls@1.2.1: {} - prettier-plugin-tailwindcss@0.6.6(prettier@3.3.3): + prettier-plugin-tailwindcss@0.6.8(prettier@3.3.3): dependencies: prettier: 3.3.3 @@ -7648,7 +7960,7 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-hook-form@7.52.2(react@18.3.1): + react-hook-form@7.53.0(react@18.3.1): dependencies: react: 18.3.1 @@ -7658,16 +7970,16 @@ snapshots: react-is@18.2.0: {} - react-router-dom@6.26.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@remix-run/router': 1.19.1 + '@remix-run/router': 1.19.2 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-router: 6.26.1(react@18.3.1) + react-router: 6.26.2(react@18.3.1) - react-router@6.26.1(react@18.3.1): + react-router@6.26.2(react@18.3.1): dependencies: - '@remix-run/router': 1.19.1 + '@remix-run/router': 1.19.2 react: 18.3.1 react@18.3.1: @@ -7837,6 +8149,8 @@ snapshots: dependencies: lru-cache: 6.0.0 + semver@7.6.3: {} + set-function-length@1.2.1: dependencies: define-data-property: 1.1.4 @@ -7887,6 +8201,8 @@ snapshots: source-map-js@1.2.0: {} + source-map-js@1.2.1: {} + source-map-support@0.5.13: dependencies: buffer-from: 1.1.2 @@ -8031,7 +8347,7 @@ snapshots: tailwind-merge@2.5.2: {} - tailwindcss@3.4.10(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)): + tailwindcss@3.4.13(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -8047,11 +8363,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.0.1 - postcss: 8.4.41 - postcss-import: 15.1.0(postcss@8.4.41) - postcss-js: 4.0.1(postcss@8.4.41) - postcss-load-config: 4.0.2(postcss@8.4.41)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) - postcss-nested: 6.0.1(postcss@8.4.41) + postcss: 8.4.47 + postcss-import: 15.1.0(postcss@8.4.47) + postcss-js: 4.0.1(postcss@8.4.47) + postcss-load-config: 4.0.2(postcss@8.4.47)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) + postcss-nested: 6.0.1(postcss@8.4.47) postcss-selector-parser: 6.0.15 resolve: 1.22.8 sucrase: 3.35.0 @@ -8111,49 +8427,49 @@ snapshots: dependencies: punycode: 2.3.1 - ts-api-utils@1.3.0(typescript@5.5.4): + ts-api-utils@1.3.0(typescript@5.6.2): dependencies: - typescript: 5.5.4 + typescript: 5.6.2 ts-interface-checker@0.1.13: {} - ts-jest@29.2.4(@babel/core@7.24.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.0))(esbuild@0.23.1)(jest@29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)))(typescript@5.5.4): + ts-jest@29.2.5(@babel/core@7.24.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.0))(esbuild@0.24.0)(jest@29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)))(typescript@5.6.2): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@20.16.1)(ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4)) + jest: 29.7.0(@types/node@20.16.9)(ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2)) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 - semver: 7.6.0 - typescript: 5.5.4 + semver: 7.6.3 + typescript: 5.6.2 yargs-parser: 21.1.1 optionalDependencies: '@babel/core': 7.24.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.24.0) - esbuild: 0.23.1 + esbuild: 0.24.0 ts-log@2.2.5: {} - ts-node@10.9.2(@types/node@20.16.1)(typescript@5.5.4): + ts-node@10.9.2(@types/node@20.16.9)(typescript@5.6.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.9 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.16.1 + '@types/node': 20.16.9 acorn: 8.11.3 acorn-walk: 8.3.2 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.5.4 + typescript: 5.6.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optional: true @@ -8162,7 +8478,7 @@ snapshots: tsscmp@1.0.6: {} - tsx@4.17.0: + tsx@4.19.1: dependencies: esbuild: 0.23.1 get-tsconfig: 4.7.5 @@ -8218,7 +8534,7 @@ snapshots: is-typed-array: 1.1.13 possible-typed-array-names: 1.0.0 - typescript@5.5.4: {} + typescript@5.6.2: {} unbox-primitive@1.0.2: dependencies: diff --git a/scripts/build.ts b/scripts/build.ts index 8365cb9a..48f773f9 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,10 +1,10 @@ /* eslint-disable no-console */ // cSpell:ignore metafile,iife,outdir,servedir -import sade from 'sade' -import path from 'node:path' -import fs from 'node:fs' -import esbuild from 'esbuild' +import sade from 'sade'; +import path from 'node:path'; +import fs from 'node:fs'; +import esbuild from 'esbuild'; import { BuildArgs, CHANNELS, @@ -13,10 +13,10 @@ import { options, SERVE_PORTS, Target, - TARGETS -} from '../esbuild/config' -import { getDevOptions } from '../esbuild/dev' -import { getProdOptions } from '../esbuild/prod' + TARGETS, +} from '../esbuild/config'; +import { getDevOptions } from '../esbuild/dev'; +import { getProdOptions } from '../esbuild/prod'; sade('build [target]', true) .option('--channel', `One of: ${CHANNELS.join(', ')}`, 'nightly') @@ -25,69 +25,69 @@ sade('build [target]', true) .example('firefox --channel=stable') .describe(['`target` should be one of ' + TARGETS.join(', ')]) .action(async (target: Target, opts: BuildArgs) => { - const options = { ...opts, target } + const options = { ...opts, target }; if (!options.target && !options.dev) { - console.log(`Building all targets with channel: ${options.channel}`) - return Promise.all(TARGETS.map((t) => build({ ...options, target: t }))) + console.log(`Building all targets with channel: ${options.channel}`); + return Promise.all(TARGETS.map((t) => build({ ...options, target: t }))); } // Default to chrome in dev build if (options.dev) { - options.target ||= 'chrome' + options.target ||= 'chrome'; } if (!TARGETS.includes(options.target)) { - console.warn('Invalid --target. Must be one of ' + TARGETS.join(', ')) - process.exit(1) + console.warn('Invalid --target. Must be one of ' + TARGETS.join(', ')); + process.exit(1); } if (!CHANNELS.includes(options.channel)) { - console.warn('Invalid --channel. Must be one of ' + CHANNELS.join(', ')) - process.exit(1) + console.warn('Invalid --channel. Must be one of ' + CHANNELS.join(', ')); + process.exit(1); } console.log( - `Building target: "${options.target}" with channel: "${options.channel}"` - ) - return options.dev ? buildWatch(options) : build(options) + `Building target: "${options.target}" with channel: "${options.channel}"`, + ); + return options.dev ? buildWatch(options) : build(options); }) - .parse(process.argv) + .parse(process.argv); async function build({ target, channel }: BuildArgs) { - const OUTPUT_DIR = path.join(DIST_DIR, target) + const OUTPUT_DIR = path.join(DIST_DIR, target); const result = await esbuild.build({ ...options, ...getProdOptions({ outDir: OUTPUT_DIR, target, channel }), - outdir: OUTPUT_DIR - }) + outdir: OUTPUT_DIR, + }); if (result.metafile) { fs.writeFileSync( path.join(OUTPUT_DIR, 'meta.json'), - JSON.stringify(result.metafile) - ) + JSON.stringify(result.metafile), + ); } } async function buildWatch({ target, channel }: BuildArgs) { - const OUTPUT_DIR = path.join(DEV_DIR, target) + const OUTPUT_DIR = path.join(DEV_DIR, target); const ctx = await esbuild.context({ ...options, ...getDevOptions({ outDir: OUTPUT_DIR, target, channel }), - outdir: OUTPUT_DIR - }) + outdir: OUTPUT_DIR, + }); try { await ctx.serve({ host: 'localhost', port: SERVE_PORTS[target], - servedir: OUTPUT_DIR - }) + servedir: OUTPUT_DIR, + }); } catch (error) { - console.log(error.message) - console.log('>>> PLEASE TRY SAVING BUILD SCRIPT AGAIN') + console.log(error.message); + console.log('>>> PLEASE TRY SAVING BUILD SCRIPT AGAIN'); } - await ctx.watch() + await ctx.watch(); - process.on('beforeExit', () => ctx.dispose()) + process.on('beforeExit', () => ctx.dispose()); } diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 63a8892b..bb084cc4 100755 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -15,16 +15,29 @@ "message": "Action Required!" }, "missingHostPermission_state_text": { - "message": "Permission to access your data for all websites is required to use Web Monetization." + "message": "Permission to access your data on all websites is required to use Web Monetization." }, - "siteNotMonetized_state_text": { + "notMonetized_text_allInvalid": { + "message": "You cannot make payments to this website at the moment.", + "description": "We cannot send money (probable cause: un-peered wallets)" + }, + "notMonetized_text_noLinks": { "message": "This website is not monetized." }, + "notMonetized_text_newTab": { + "message": "The extension does not support empty tabs." + }, + "notMonetized_text_internalPage": { + "message": "The extension cannot access the browser's internal pages." + }, + "notMonetized_text_unsupportedScheme": { + "message": "Web Monetization only works with websites using https://." + }, "keyRevoked_error_title": { - "message": "Unauthorized access to wallet" + "message": "Unauthorized access to the wallet." }, "keyRevoked_error_text": { - "message": "It appears that the key has been revoked from your wallet. Authenticate wallet by adding the key again, or disconnect." + "message": "It appears the key has been revoked from your wallet. Please authenticate by adding the key again, or disconnect." }, "keyRevoked_action_disconnect": { "message": "Yes, let me disconnect my wallet" @@ -36,10 +49,10 @@ "message": "Reconnect" }, "pay_error_notEnoughFunds": { - "message": "Not enough funds to facilitate payment." + "message": "Insufficient funds to complete the payment." }, "pay_error_invalidReceivers": { - "message": "At the moment, you can not pay this website.", + "message": "At the moment, you cannot pay this website.", "description": "We cannot send money (probable cause: un-peered wallets)" }, "pay_error_notMonetized": { @@ -49,7 +62,7 @@ "message": "Out of funds" }, "outOfFunds_error_text": { - "message": "Funds have been depleted. You can no longer make payments." + "message": "Funds are depleted. You can no longer make payments." }, "outOfFunds_error_textHint": { "message": "Please add funds." @@ -77,7 +90,7 @@ "message": "Enter the amount to add from the wallet." }, "outOfFundsAddFunds_label_amountDescriptionRecurring": { - "message": "Enter the amount to add from the wallet. This amount will renew automatically every month (next: $NEXT_RENEW_DATE$).", + "message": "Enter the amount to add from your wallet. This amount will renew automatically each month (next: $NEXT_RENEW_DATE$).", "placeholders": { "NEXT_RENEW_DATE": { "content": "$1", "example": "Aug 22, 2024" } } @@ -88,10 +101,109 @@ "outOfFundsAddFunds_action_addRecurring": { "message": "Add funds" }, + "connectWallet_text_title": { + "message": "Let's get you set up!" + }, + "connectWallet_text_desc": { + "message": "Just a few quick steps to connect the extension to your wallet" + }, + "connectWallet_label_walletAddress": { + "message": "Enter your wallet address/payment pointer" + }, + "connectWallet_labelGroup_amount": { + "message": "Enter the amount to allocate from your wallet" + }, + "connectWallet_label_amount": { + "message": "Amount" + }, + "connectWallet_label_recurring": { + "message": "Renew monthly" + }, + "connectWallet_label_publicKey": { + "message": "Please copy this key, paste it manually into your wallet, and then connect." + }, + "connectWallet_text_publicKeyLearnMore": { + "message": "Learn more." + }, + "connectWallet_action_connect": { + "message": "Connect" + }, + "connectWallet_error_urlRequired": { + "message": "The wallet address is required." + }, + "connectWallet_error_urlInvalidUrl": { + "message": "Invalid wallet address URL." + }, + "connectWallet_error_urlInvalidNotHttps": { + "message": "The wallet address must be a valid https:// URL or a payment pointer." + }, + "connectWallet_error_amountRequired": { + "message": "The amount is required." + }, + "connectWallet_error_amountInvalidNumber": { + "message": "Please enter a valid number for the amount." + }, + "connectWallet_error_amountMinimum": { + "message": "An amount greater than $AMOUNT$ is required.", + "placeholders": { + "AMOUNT": { "content": "$1", "example": "$2.05" } + } + }, + "connectWallet_error_failedAutoKeyAdd": { + "message": "Unable to automatically connect to your wallet." + }, + "connectWallet_error_failedAutoKeyAddWhy": { + "message": "Why?", + "description": "Click here to see the reason why automatic key addition failed." + }, "connectWallet_error_invalidClient": { - "message": "Failed to connect. Please make sure you have added the public key to the correct wallet address." + "message": "Connection failed. Please ensure the key is added to the correct wallet." + }, + "connectWallet_error_tabClosed": { + "message": "Wallet connection cancelled. The tab was closed before completion." + }, + "connectWallet_error_grantRejected": { + "message": "Wallet connection cancelled. The request was not authorized." + }, + "connectWalletKeyService_text_consentP1": { + "message": "We will automatically connect with your wallet provider." + }, + "connectWalletKeyService_text_consentLearnMore": { + "message": "Learn more", + "description": "Learn more about how this works" + }, + "connectWalletKeyService_text_consentP2": { + "message": "By agreeing, you provide us consent to automatically access your wallet to securely add a key." + }, + "connectWalletKeyService_text_consentP3": { + "message": "Please note, this process does not involve accessing or handling your funds." + }, + "connectWalletKeyService_label_consentAccept": { + "message": "Agree" + }, + "connectWalletKeyService_label_consentDecline": { + "message": "Decline" + }, + "connectWalletKeyService_error_notImplemented": { + "message": "Automatic key addition is not yet implemented for the selected wallet provider." + }, + "connectWalletKeyService_error_noConsent": { + "message": "You declined consent for automatic key addition." + }, + "connectWalletKeyService_error_failed": { + "message": "Automatic key addition failed at step “$STEP_ID$” with message “$MESSAGE$”.", + "placeholders": { + "STEP_ID": { "content": "$1", "example": "Doing something" }, + "MESSAGE": { "content": "$2", "example": "Could not do something" } + } + }, + "connectWalletKeyService_error_timeoutLogin": { + "message": "The login attempt has timed out." + }, + "connectWalletKeyService_error_skipAlreadyLoggedIn": { + "message": "You are already logged in." }, - "allInvalidLinks_state_text": { - "message": "At the moment, you can not pay this website." + "connectWalletKeyService_error_accountNotFound": { + "message": "Failed to find an account for the provided wallet address. Are you logged into a different account?" } } diff --git a/src/background/config.ts b/src/background/config.ts index cf71db34..b0248b46 100644 --- a/src/background/config.ts +++ b/src/background/config.ts @@ -1,9 +1,9 @@ -export const DEFAULT_SCALE = 2 -export const DEFAULT_INTERVAL_MS = 3_600_000 +export const DEFAULT_SCALE = 2; +export const DEFAULT_INTERVAL_MS = 3_600_000; -export const DEFAULT_RATE_OF_PAY = '60' -export const MIN_RATE_OF_PAY = '1' -export const MAX_RATE_OF_PAY = '100' +export const DEFAULT_RATE_OF_PAY = '60'; +export const MIN_RATE_OF_PAY = '1'; +export const MAX_RATE_OF_PAY = '100'; export const EXCHANGE_RATES_URL = - 'https://telemetry-exchange-rates.s3.amazonaws.com/exchange-rates-usd.json' + 'https://telemetry-exchange-rates.s3.amazonaws.com/exchange-rates-usd.json'; 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 3fa7ddd3..5c3e89c8 100644 --- a/src/background/container.ts +++ b/src/background/container.ts @@ -1,5 +1,5 @@ -import { asClass, asValue, createContainer, InjectionMode } from 'awilix' -import browser, { type Browser } from 'webextension-polyfill' +import { asClass, asValue, createContainer, InjectionMode } from 'awilix'; +import browser, { type Browser } from 'webextension-polyfill'; import { OpenPaymentsService, StorageService, @@ -7,77 +7,97 @@ import { Background, TabEvents, TabState, + WindowState, SendToPopup, EventsService, Heartbeat, - Deduplicator -} from './services' -import { createLogger, Logger } from '@/shared/logger' -import { LOG_LEVEL } from '@/shared/defines' -import { tFactory, type Translation } from '@/shared/helpers' + Deduplicator, +} from './services'; +import { createLogger, Logger } from '@/shared/logger'; +import { LOG_LEVEL } from '@/shared/defines'; +import { + getBrowserName, + tFactory, + type BrowserName, + type Translation, +} from '@/shared/helpers'; +import { + MessageManager, + type BackgroundToContentMessage, +} from '@/shared/messages'; export interface Cradle { - logger: Logger - browser: Browser - events: EventsService - deduplicator: Deduplicator - storage: StorageService - openPaymentsService: OpenPaymentsService - monetizationService: MonetizationService - sendToPopup: SendToPopup - tabEvents: TabEvents - background: Background - t: Translation - tabState: TabState - heartbeat: Heartbeat + logger: Logger; + browser: Browser; + browserName: BrowserName; + events: EventsService; + deduplicator: Deduplicator; + storage: StorageService; + openPaymentsService: OpenPaymentsService; + monetizationService: MonetizationService; + message: MessageManager; + sendToPopup: SendToPopup; + tabEvents: TabEvents; + background: Background; + t: Translation; + tabState: TabState; + windowState: WindowState; + heartbeat: Heartbeat; } export const configureContainer = () => { const container = createContainer({ - injectionMode: InjectionMode.PROXY - }) + injectionMode: InjectionMode.PROXY, + }); - const logger = createLogger(LOG_LEVEL) + const logger = createLogger(LOG_LEVEL); 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) .singleton() .inject(() => ({ - logger: logger.getLogger('deduplicator') + logger: logger.getLogger('deduplicator'), })), storage: asClass(StorageService) .singleton() .inject(() => ({ - logger: logger.getLogger('storage') + logger: logger.getLogger('storage'), })), openPaymentsService: asClass(OpenPaymentsService) .singleton() .inject(() => ({ - logger: logger.getLogger('open-payments') + logger: logger.getLogger('open-payments'), })), monetizationService: asClass(MonetizationService) .singleton() .inject(() => ({ - logger: logger.getLogger('monetization') + logger: logger.getLogger('monetization'), })), + message: asClass(MessageManager).singleton(), tabEvents: asClass(TabEvents).singleton(), sendToPopup: asClass(SendToPopup).singleton(), background: asClass(Background) .singleton() .inject(() => ({ - logger: logger.getLogger('main') + logger: logger.getLogger('main'), })), tabState: asClass(TabState) .singleton() .inject(() => ({ - logger: logger.getLogger('tab-state') + logger: logger.getLogger('tab-state'), + })), + windowState: asClass(WindowState) + .singleton() + .inject(() => ({ + logger: logger.getLogger('window-state'), })), - heartbeat: asClass(Heartbeat).singleton() - }) + heartbeat: asClass(Heartbeat).singleton(), + }); - return container -} + return container; +}; diff --git a/src/background/globalBuffer.ts b/src/background/globalBuffer.ts index cbd0f311..2c012c98 100644 --- a/src/background/globalBuffer.ts +++ b/src/background/globalBuffer.ts @@ -1,3 +1,3 @@ -import { Buffer } from 'safe-buffer' +import { Buffer } from 'safe-buffer'; // @ts-expect-error we know -globalThis.Buffer = Buffer +globalThis.Buffer = Buffer; diff --git a/src/background/index.ts b/src/background/index.ts index b52014ee..b8230590 100755 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,5 +1,5 @@ -import './globalBuffer' -import { configureContainer } from './container' +import './globalBuffer'; +import { configureContainer } from './container'; -const container = configureContainer() -container.resolve('background').start() +const container = configureContainer(); +container.resolve('background').start(); diff --git a/src/background/lib/messages.ts b/src/background/lib/messages.ts deleted file mode 100644 index c5183eb3..00000000 --- a/src/background/lib/messages.ts +++ /dev/null @@ -1,35 +0,0 @@ -import browser from 'webextension-polyfill' -import { - BackgroundToContentMessage, - BackgroundToContentAction, - BackgroundToContentActionPayload, - MessageManager -} from '@/shared/messages' - -export const message = new MessageManager(browser) - -interface SendMonetizationEventParams { - tabId: number - frameId: number - payload: BackgroundToContentActionPayload[BackgroundToContentAction.MONETIZATION_EVENT] -} - -export const sendMonetizationEvent = async ({ - tabId, - frameId, - payload -}: SendMonetizationEventParams) => { - return await message.sendToTab(tabId, frameId, { - action: BackgroundToContentAction.MONETIZATION_EVENT, - payload - }) -} - -export const emitToggleWM = async ( - payload: BackgroundToContentActionPayload[BackgroundToContentAction.EMIT_TOGGLE_WM] -) => { - return await message.sendToActiveTab({ - action: BackgroundToContentAction.EMIT_TOGGLE_WM, - payload - }) -} diff --git a/src/background/services/background.ts b/src/background/services/background.ts index 433cd8dc..54ccf2dc 100644 --- a/src/background/services/background.ts +++ b/src/background/services/background.ts @@ -1,33 +1,32 @@ -import type { Browser } from 'webextension-polyfill' -import { - type ToBackgroundMessage, - PopupToBackgroundAction, - ContentToBackgroundAction -} from '@/shared/messages' +import type { Browser } from 'webextension-polyfill'; +import type { ToBackgroundMessage } from '@/shared/messages'; import { + errorWithKeyToJSON, failure, getNextOccurrence, getWalletInformation, - success -} from '@/shared/helpers' -import { OpenPaymentsClientError } from '@interledger/open-payments/dist/client/error' -import { getCurrentActiveTab, OPEN_PAYMENTS_ERRORS } from '@/background/utils' -import { PERMISSION_HOSTS } from '@/shared/defines' -import type { Cradle } from '@/background/container' + isErrorWithKey, + success, +} from '@/shared/helpers'; +import { OpenPaymentsClientError } from '@interledger/open-payments/dist/client/error'; +import { getTab, OPEN_PAYMENTS_ERRORS } from '@/background/utils'; +import { PERMISSION_HOSTS } from '@/shared/defines'; +import type { Cradle } from '@/background/container'; -type AlarmCallback = Parameters[0] -const ALARM_RESET_OUT_OF_FUNDS = 'reset-out-of-funds' +type AlarmCallback = Parameters[0]; +const ALARM_RESET_OUT_OF_FUNDS = 'reset-out-of-funds'; export class Background { - private browser: Cradle['browser'] - private openPaymentsService: Cradle['openPaymentsService'] - private monetizationService: Cradle['monetizationService'] - private storage: Cradle['storage'] - private logger: Cradle['logger'] - private tabEvents: Cradle['tabEvents'] - private sendToPopup: Cradle['sendToPopup'] - private events: Cradle['events'] - private heartbeat: Cradle['heartbeat'] + private browser: Cradle['browser']; + private openPaymentsService: Cradle['openPaymentsService']; + private monetizationService: Cradle['monetizationService']; + 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']; constructor({ browser, @@ -36,9 +35,10 @@ export class Background { storage, logger, tabEvents, + windowState, sendToPopup, events, - heartbeat + heartbeat, }: Cradle) { Object.assign(this, { browser, @@ -47,240 +47,312 @@ export class Background { storage, sendToPopup, tabEvents, + windowState, logger, events, - heartbeat - }) + heartbeat, + }); } async start() { - this.bindOnInstalled() - await this.onStart() - this.heartbeat.start() - this.bindMessageHandler() - this.bindPermissionsHandler() - this.bindEventsHandler() - this.bindTabHandlers() - this.bindWindowHandlers() - this.sendToPopup.start() + this.bindOnInstalled(); + await this.injectPolyfill(); + await this.onStart(); + this.heartbeat.start(); + this.bindMessageHandler(); + this.bindPermissionsHandler(); + this.bindEventsHandler(); + this.bindTabHandlers(); + this.bindWindowHandlers(); + this.sendToPopup.start(); + } + + // TODO: When Firefox 128 is old enough, inject directly via manifest. + // Also see: injectPolyfill in contentScript + // See: https://github.com/interledger/web-monetization-extension/issues/607 + async injectPolyfill() { + try { + await this.browser.scripting.registerContentScripts([ + { + world: 'MAIN', + id: 'polyfill', + allFrames: true, + js: ['polyfill/polyfill.js'], + matches: PERMISSION_HOSTS.origins, + runAt: 'document_start', + }, + ]); + } catch (error) { + // Firefox <128 will throw saying world: MAIN isn't supported. So, we'll + // inject via contentScript later. Injection via contentScript is slow, + // but apart from WM detection on page-load, everything else works fine. + if (!error.message.includes(`world`)) { + this.logger.error( + `Content script execution world \`MAIN\` not supported by your browser.\n` + + `Check https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld#browser_compatibility for browser compatibility.`, + error, + ); + } + } } async onStart() { - await this.storage.populate() - await this.checkPermissions() - await this.scheduleResetOutOfFundsState() + 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(); } async scheduleResetOutOfFundsState() { // Reset out_of_funds state, we'll detect latest state as we make a payment. - await this.storage.setState({ out_of_funds: false }) + await this.storage.setState({ out_of_funds: false }); - const { recurringGrant } = await this.storage.get(['recurringGrant']) - if (!recurringGrant) return + const { recurringGrant } = await this.storage.get(['recurringGrant']); + if (!recurringGrant) return; - const renewDate = getNextOccurrence(recurringGrant.amount.interval) + const renewDate = getNextOccurrence(recurringGrant.amount.interval); this.browser.alarms.create(ALARM_RESET_OUT_OF_FUNDS, { - when: renewDate.valueOf() - }) + when: renewDate.valueOf(), + }); const resetOutOfFundsState: AlarmCallback = (alarm) => { - if (alarm.name !== ALARM_RESET_OUT_OF_FUNDS) return - this.storage.setState({ out_of_funds: false }) - this.browser.alarms.onAlarm.removeListener(resetOutOfFundsState) - } - this.browser.alarms.onAlarm.addListener(resetOutOfFundsState) + if (alarm.name !== ALARM_RESET_OUT_OF_FUNDS) return; + this.storage.setState({ out_of_funds: false }); + this.browser.alarms.onAlarm.removeListener(resetOutOfFundsState); + }; + this.browser.alarms.onAlarm.addListener(resetOutOfFundsState); } 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'] - }) - 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 - } + windowTypes: ['normal'], + }); + 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); + } } - }) - }) + } + }); } bindTabHandlers() { - this.browser.tabs.onRemoved.addListener(this.tabEvents.onRemovedTab) - this.browser.tabs.onUpdated.addListener(this.tabEvents.onUpdatedTab) - this.browser.tabs.onCreated.addListener(this.tabEvents.onCreatedTab) - this.browser.tabs.onActivated.addListener(this.tabEvents.onActivatedTab) + this.browser.tabs.onRemoved.addListener(this.tabEvents.onRemovedTab); + this.browser.tabs.onUpdated.addListener(this.tabEvents.onUpdatedTab); + this.browser.tabs.onCreated.addListener(this.tabEvents.onCreatedTab); + this.browser.tabs.onActivated.addListener(this.tabEvents.onActivatedTab); } bindMessageHandler() { this.browser.runtime.onMessage.addListener( async (message: ToBackgroundMessage, sender) => { - this.logger.debug('Received message', message) + this.logger.debug('Received message', message); try { switch (message.action) { - case PopupToBackgroundAction.GET_CONTEXT_DATA: - return success(await this.monetizationService.getPopupData()) - - case PopupToBackgroundAction.CONNECT_WALLET: - await this.openPaymentsService.connectWallet(message.payload) - if (message.payload.recurring) { - this.scheduleResetOutOfFundsState() + // region Popup + case 'GET_CONTEXT_DATA': + return success( + await this.monetizationService.getPopupData( + await this.windowState.getCurrentTab(), + ), + ); + + case 'CONNECT_WALLET': + await this.openPaymentsService.connectWallet(message.payload); + if (message.payload?.recurring) { + this.scheduleResetOutOfFundsState(); } - return + return success(undefined); - case PopupToBackgroundAction.RECONNECT_WALLET: { - await this.openPaymentsService.reconnectWallet() - await this.monetizationService.resumePaymentSessionActiveTab() - await this.updateVisualIndicatorsForCurrentTab() - return success(undefined) + case 'RECONNECT_WALLET': { + await this.openPaymentsService.reconnectWallet(); + await this.monetizationService.resumePaymentSessionActiveTab(); + await this.updateVisualIndicatorsForCurrentTab(); + return success(undefined); } - case PopupToBackgroundAction.ADD_FUNDS: - await this.openPaymentsService.addFunds(message.payload) - await this.browser.alarms.clear(ALARM_RESET_OUT_OF_FUNDS) + case 'ADD_FUNDS': + await this.openPaymentsService.addFunds(message.payload); + await this.browser.alarms.clear(ALARM_RESET_OUT_OF_FUNDS); if (message.payload.recurring) { - this.scheduleResetOutOfFundsState() + this.scheduleResetOutOfFundsState(); } - return - - case PopupToBackgroundAction.DISCONNECT_WALLET: - await this.openPaymentsService.disconnectWallet() - await this.browser.alarms.clear(ALARM_RESET_OUT_OF_FUNDS) - await this.updateVisualIndicatorsForCurrentTab() - this.sendToPopup.send('SET_STATE', { state: {}, prevState: {} }) - return - - case PopupToBackgroundAction.TOGGLE_WM: { - await this.monetizationService.toggleWM() - await this.updateVisualIndicatorsForCurrentTab() - return + return; + + case 'DISCONNECT_WALLET': + await this.openPaymentsService.disconnectWallet(); + await this.browser.alarms.clear(ALARM_RESET_OUT_OF_FUNDS); + await this.updateVisualIndicatorsForCurrentTab(); + this.sendToPopup.send('SET_STATE', { state: {}, prevState: {} }); + return; + + case 'TOGGLE_WM': { + await this.monetizationService.toggleWM(); + await this.updateVisualIndicatorsForCurrentTab(); + return; } - case PopupToBackgroundAction.PAY_WEBSITE: + case 'UPDATE_RATE_OF_PAY': + return success( + await this.storage.updateRate(message.payload.rateOfPay), + ); + + case 'PAY_WEBSITE': return success( - await this.monetizationService.pay(message.payload.amount) - ) + await this.monetizationService.pay(message.payload.amount), + ); + + // endregion - case ContentToBackgroundAction.CHECK_WALLET_ADDRESS_URL: + // region Content + case 'GET_WALLET_ADDRESS_INFO': return success( - await getWalletInformation(message.payload.walletAddressUrl) - ) + await getWalletInformation(message.payload.walletAddressUrl), + ); - case ContentToBackgroundAction.START_MONETIZATION: + case 'TAB_FOCUSED': + await this.tabEvents.onFocussedTab(getTab(sender)); + return; + + case 'START_MONETIZATION': await this.monetizationService.startPaymentSession( message.payload, - sender - ) - return + sender, + ); + return; - case ContentToBackgroundAction.STOP_MONETIZATION: + case 'STOP_MONETIZATION': await this.monetizationService.stopPaymentSession( message.payload, - sender - ) - return + sender, + ); + return; - case ContentToBackgroundAction.RESUME_MONETIZATION: + case 'RESUME_MONETIZATION': await this.monetizationService.resumePaymentSession( message.payload, - sender - ) - return - - case PopupToBackgroundAction.UPDATE_RATE_OF_PAY: - return success( - await this.storage.updateRate(message.payload.rateOfPay) - ) + sender, + ); + return; - case ContentToBackgroundAction.IS_WM_ENABLED: - return success(await this.storage.getWMState()) + // endregion default: - return + return; } } catch (e) { + if (isErrorWithKey(e)) { + this.logger.error(message.action, e); + return failure(errorWithKeyToJSON(e)); + } if (e instanceof OpenPaymentsClientError) { - this.logger.error(message.action, e.message, e.description) - return failure(OPEN_PAYMENTS_ERRORS[e.description] ?? e.description) + this.logger.error(message.action, e.message, e.description); + return failure( + OPEN_PAYMENTS_ERRORS[e.description] ?? e.description, + ); } - this.logger.error(message.action, e.message) - return failure(e.message) + this.logger.error(message.action, e.message); + return failure(e.message); } - } - ) + }, + ); } private async updateVisualIndicatorsForCurrentTab() { - const activeTab = await getCurrentActiveTab(this.browser) + const activeTab = await this.windowState.getCurrentTab(); if (activeTab?.id) { - void this.tabEvents.updateVisualIndicators(activeTab.id) + void this.tabEvents.updateVisualIndicators(activeTab); } } bindPermissionsHandler() { - this.browser.permissions.onAdded.addListener(this.checkPermissions) - this.browser.permissions.onRemoved.addListener(this.checkPermissions) + this.browser.permissions.onAdded.addListener(this.checkPermissions); + this.browser.permissions.onRemoved.addListener(this.checkPermissions); } bindEventsHandler() { this.events.on('storage.state_update', async ({ state, prevState }) => { - this.sendToPopup.send('SET_STATE', { state, prevState }) - await this.updateVisualIndicatorsForCurrentTab() - }) + this.sendToPopup.send('SET_STATE', { state, prevState }); + await this.updateVisualIndicatorsForCurrentTab(); + }); + + this.events.on('monetization.state_update', async (tabId) => { + const tab = await this.browser.tabs.get(tabId); + void this.tabEvents.updateVisualIndicators(tab); + }); - this.events.on('monetization.state_update', (tabId) => { - void this.tabEvents.updateVisualIndicators(tabId) - }) + this.events.on('storage.popup_transient_state_update', (state) => { + this.sendToPopup.send('SET_TRANSIENT_STATE', state); + }); this.events.on('storage.balance_update', (balance) => - this.sendToPopup.send('SET_BALANCE', balance) - ) + this.sendToPopup.send('SET_BALANCE', balance), + ); } bindOnInstalled() { this.browser.runtime.onInstalled.addListener(async (details) => { - const data = await this.storage.get() - this.logger.info(data) + const data = await this.storage.get(); + this.logger.info(data); if (details.reason === 'install') { - await this.storage.populate() - await this.openPaymentsService.generateKeys() + await this.storage.populate(); + await this.openPaymentsService.generateKeys(); } else if (details.reason === 'update') { - const migrated = await this.storage.migrate() + const migrated = await this.storage.migrate(); if (migrated) { - const prevVersion = data.version ?? 1 + const prevVersion = data.version ?? 1; this.logger.info( - `Migrated from ${prevVersion} to ${migrated.version}` - ) + `Migrated from ${prevVersion} to ${migrated.version}`, + ); } } - }) + }); } checkPermissions = async () => { try { - this.logger.debug('checking hosts permission') + this.logger.debug('checking hosts permission'); const hasPermissions = - await this.browser.permissions.contains(PERMISSION_HOSTS) - this.storage.setState({ missing_host_permissions: !hasPermissions }) + await this.browser.permissions.contains(PERMISSION_HOSTS); + this.storage.setState({ missing_host_permissions: !hasPermissions }); } catch (error) { - this.logger.error(error) + this.logger.error(error); } - } + }; } diff --git a/src/background/services/deduplicator.ts b/src/background/services/deduplicator.ts index b5856e55..589fc09d 100644 --- a/src/background/services/deduplicator.ts +++ b/src/background/services/deduplicator.ts @@ -1,77 +1,77 @@ -import type { Cradle } from '../container' +import type { Cradle } from '../container'; -type AsyncFn = (...args: any[]) => Promise +type AsyncFn = (...args: any[]) => Promise; interface CacheEntry { - promise: Promise + promise: Promise; } interface DedupeOptions { - cacheFnArgs: boolean - wait: number + cacheFnArgs: boolean; + wait: number; } export class Deduplicator { - private logger: Cradle['logger'] + private logger: Cradle['logger']; - private cache: Map = new Map() + private cache: Map = new Map(); constructor({ logger }: Cradle) { - Object.assign(this, { logger }) + Object.assign(this, { logger }); } dedupe>( fn: T, - { cacheFnArgs = false, wait = 5000 }: Partial = {} + { cacheFnArgs = false, wait = 5000 }: Partial = {}, ): T { return ((...args: Parameters): ReturnType => { - const key = this.generateCacheKey(fn, args, cacheFnArgs) - const entry = this.cache.get(key) + const key = this.generateCacheKey(fn, args, cacheFnArgs); + const entry = this.cache.get(key); if (entry) { this.logger.debug( - `Deduplicating function=${fn.name}, ${cacheFnArgs ? 'args=' + JSON.stringify(args) : 'without args'}` - ) - return entry.promise as ReturnType + `Deduplicating function=${fn.name}, ${cacheFnArgs ? 'args=' + JSON.stringify(args) : 'without args'}`, + ); + return entry.promise as ReturnType; } - const promise = fn(...args) - this.cache.set(key, { promise }) + const promise = fn(...args); + this.cache.set(key, { promise }); promise .then((res) => { - this.cache.set(key, { promise: Promise.resolve(res) }) - return res + this.cache.set(key, { promise: Promise.resolve(res) }); + return res; }) .catch((err) => { - throw err + throw err; }) - .finally(() => this.scheduleCacheClear(key, wait)) + .finally(() => this.scheduleCacheClear(key, wait)); - return promise as ReturnType - }) as unknown as T + return promise as ReturnType; + }) as unknown as T; } private generateCacheKey( fn: AsyncFn, args: any[], - cacheFnArgs: boolean + cacheFnArgs: boolean, ): string { - let key = fn.name + let key = fn.name; if (cacheFnArgs) { - key += `_${JSON.stringify(args)}` + key += `_${JSON.stringify(args)}`; } - return key + return key; } private scheduleCacheClear(key: string, wait: number): void { setTimeout(() => { - this.logger.debug(`Attempting to remove key=${key} from cache.`) - const entry = this.cache.get(key) + this.logger.debug(`Attempting to remove key=${key} from cache.`); + const entry = this.cache.get(key); if (entry) { - this.logger.debug(`Removing key=${key} from cache.`) - this.cache.delete(key) + this.logger.debug(`Removing key=${key} from cache.`); + this.cache.delete(key); } - }, wait) + }, wait); } } diff --git a/src/background/services/events.ts b/src/background/services/events.ts index bd4e12f8..9221c737 100644 --- a/src/background/services/events.ts +++ b/src/background/services/events.ts @@ -1,39 +1,45 @@ -import { EventEmitter } from 'events' -import type { AmountValue, Storage, TabId } from '@/shared/types' +import { EventEmitter } from 'events'; +import type { + AmountValue, + PopupTransientState, + Storage, + TabId, +} from '@/shared/types'; interface BackgroundEvents { - 'open_payments.key_revoked': void - 'open_payments.out_of_funds': void - 'open_payments.invalid_receiver': { tabId: number } - 'storage.rate_of_pay_update': { rate: string } + 'open_payments.key_revoked': void; + 'open_payments.out_of_funds': void; + 'open_payments.invalid_receiver': { tabId: number }; + 'storage.rate_of_pay_update': { rate: string }; 'storage.state_update': { - state: Storage['state'] - prevState: Storage['state'] - } + state: Storage['state']; + prevState: Storage['state']; + }; + 'storage.popup_transient_state_update': PopupTransientState; 'storage.balance_update': Record< 'recurring' | 'oneTime' | 'total', AmountValue - > - 'monetization.state_update': TabId + >; + 'monetization.state_update': TabId; } export class EventsService extends EventEmitter { constructor() { - super() + super(); } on( eventName: TEvent, - listener: (param: BackgroundEvents[TEvent]) => void + listener: (param: BackgroundEvents[TEvent]) => void, ): this { - return super.on(eventName, listener) + return super.on(eventName, listener); } once( eventName: TEvent, - listener: (param: BackgroundEvents[TEvent]) => void + listener: (param: BackgroundEvents[TEvent]) => void, ): this { - return super.once(eventName, listener) + return super.once(eventName, listener); } emit( @@ -42,7 +48,7 @@ export class EventsService extends EventEmitter { ? [param?: BackgroundEvents[TEvent]] : [param: BackgroundEvents[TEvent]] ): boolean { - return super.emit(eventName, ...rest) + return super.emit(eventName, ...rest); } /** @@ -50,7 +56,7 @@ export class EventsService extends EventEmitter { * @deprecated */ addListener(): this { - throw new Error('Use `on` instead of `addListener`.') + throw new Error('Use `on` instead of `addListener`.'); } /** @@ -59,6 +65,6 @@ export class EventsService extends EventEmitter { */ removeListener(): this { // eslint-disable-next-line prefer-rest-params - return super.removeListener.apply(this, arguments) + return super.removeListener.apply(this, arguments); } } diff --git a/src/background/services/heartbeat.ts b/src/background/services/heartbeat.ts index 1e0d8e5b..80259487 100644 --- a/src/background/services/heartbeat.ts +++ b/src/background/services/heartbeat.ts @@ -1,14 +1,14 @@ -import type { Cradle } from '@/background/container' +import type { Cradle } from '@/background/container'; export class Heartbeat { - private browser: Cradle['browser'] + private browser: Cradle['browser']; constructor({ browser }: Cradle) { - Object.assign(this, { browser }) + Object.assign(this, { browser }); } start() { - const alarms = this.browser.alarms + const alarms = this.browser.alarms; // The minimum supported cross-browser period is 1 minute. So, we create 4 // alarms at a 0,15,30,45 seconds delay. So, we'll get an alarm every 15s - // and that'll help us keep the background script alive. @@ -18,30 +18,30 @@ export class Heartbeat { // first minute that our extension stays alive. setTimeout( () => alarms.create('keep-alive-alarm-0', { periodInMinutes: 1 }), - 0 - ) + 0, + ); setTimeout( () => alarms.create('keep-alive-alarm-1', { periodInMinutes: 1 }), - 15 * 1000 - ) + 15 * 1000, + ); setTimeout( () => alarms.create('keep-alive-alarm-2', { periodInMinutes: 1 }), - 30 * 1000 - ) + 30 * 1000, + ); setTimeout( () => alarms.create('keep-alive-alarm-3', { periodInMinutes: 1 }), - 45 * 1000 - ) + 45 * 1000, + ); alarms.onAlarm.addListener(() => { // doing nothing is enough to keep it alive - }) + }); } stop() { - this.browser.alarms.clear('keep-alive-alarm-0') - this.browser.alarms.clear('keep-alive-alarm-1') - this.browser.alarms.clear('keep-alive-alarm-2') - this.browser.alarms.clear('keep-alive-alarm-3') + this.browser.alarms.clear('keep-alive-alarm-0'); + this.browser.alarms.clear('keep-alive-alarm-1'); + this.browser.alarms.clear('keep-alive-alarm-2'); + this.browser.alarms.clear('keep-alive-alarm-3'); } } diff --git a/src/background/services/index.ts b/src/background/services/index.ts index dffd4092..d2b07f0b 100644 --- a/src/background/services/index.ts +++ b/src/background/services/index.ts @@ -1,10 +1,11 @@ -export { OpenPaymentsService } from './openPayments' -export { StorageService } from './storage' -export { MonetizationService } from './monetization' -export { Background } from './background' -export { TabEvents } from './tabEvents' -export { TabState } from './tabState' -export { SendToPopup } from './sendToPopup' -export { EventsService } from './events' -export { Deduplicator } from './deduplicator' -export { Heartbeat } from './heartbeat' +export { OpenPaymentsService } from './openPayments'; +export { StorageService } from './storage'; +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'; +export { Heartbeat } from './heartbeat'; diff --git a/src/background/services/keyAutoAdd.ts b/src/background/services/keyAutoAdd.ts new file mode 100644 index 00000000..302f764b --- /dev/null +++ b/src/background/services/keyAutoAdd.ts @@ -0,0 +1,198 @@ +import { + ErrorWithKey, + ensureEnd, + errorWithKeyToJSON, + isErrorWithKey, + withResolvers, + type ErrorWithKeyLike, +} from '@/shared/helpers'; +import type { Browser, Runtime, Tabs } from 'webextension-polyfill'; +import type { WalletAddress } from '@interledger/open-payments'; +import type { TabId } from '@/shared/types'; +import type { Cradle } from '@/background/container'; +import type { + BeginPayload, + KeyAutoAddToBackgroundMessage, +} from '@/content/keyAutoAdd/lib/types'; + +export const CONNECTION_NAME = 'key-auto-add'; + +type OnTabRemovedCallback = Parameters< + Browser['tabs']['onRemoved']['addListener'] +>[0]; +type OnConnectCallback = Parameters< + Browser['runtime']['onConnect']['addListener'] +>[0]; +type OnPortMessageListener = Parameters< + Runtime.Port['onMessage']['addListener'] +>[0]; + +export class KeyAutoAddService { + private browser: Cradle['browser']; + private storage: Cradle['storage']; + private browserName: Cradle['browserName']; + private t: Cradle['t']; + + private tab: Tabs.Tab | null = null; + + constructor({ + browser, + storage, + browserName, + t, + }: Pick) { + Object.assign(this, { browser, storage, browserName, t }); + } + + async addPublicKeyToWallet(walletAddress: WalletAddress) { + const info = walletAddressToProvider(walletAddress); + try { + const { publicKey, keyId } = await this.storage.get([ + 'publicKey', + 'keyId', + ]); + this.updateConnectState(); + await this.process(info.url, { + publicKey, + keyId, + walletAddressUrl: walletAddress.id, + nickName: this.t('appName') + ' - ' + this.browserName, + keyAddUrl: info.url, + }); + await this.validate(walletAddress.id, keyId); + } catch (error) { + if (!error.key || !error.key.startsWith('connectWallet_error_')) { + this.updateConnectState(error); + } + throw error; + } + } + + /** + * Allows re-using same tab for further processing. Available only after + * {@linkcode addPublicKeyToWallet} has been called. + */ + get tabId(): TabId | undefined { + return this.tab?.id; + } + + private async process(url: string, payload: BeginPayload) { + const { resolve, reject, promise } = withResolvers(); + + const tab = await this.browser.tabs.create({ url }); + this.tab = tab; + if (!tab.id) { + reject(new Error('Could not create tab')); + return promise; + } + + const onTabCloseListener: OnTabRemovedCallback = (tabId) => { + if (tabId !== tab.id) return; + this.browser.tabs.onRemoved.removeListener(onTabCloseListener); + reject(new ErrorWithKey('connectWallet_error_tabClosed')); + }; + this.browser.tabs.onRemoved.addListener(onTabCloseListener); + + const ports = new Set(); + const onConnectListener: OnConnectCallback = (port) => { + if (port.name !== CONNECTION_NAME) return; + if (port.error) { + reject(new Error(port.error.message)); + return; + } + ports.add(port); + + port.postMessage({ action: 'BEGIN', payload }); + + port.onMessage.addListener(onMessageListener); + + port.onDisconnect.addListener(() => { + ports.delete(port); + // wait for connect again so we can send message again if not connected, + // and not errored already (e.g. page refreshed) + }); + }; + + const onMessageListener: OnPortMessageListener = ( + message: KeyAutoAddToBackgroundMessage, + port, + ) => { + if (message.action === 'SUCCESS') { + this.browser.runtime.onConnect.removeListener(onConnectListener); + this.browser.tabs.onRemoved.removeListener(onTabCloseListener); + resolve(message.payload); + } else if (message.action === 'ERROR') { + this.browser.runtime.onConnect.removeListener(onConnectListener); + this.browser.tabs.onRemoved.removeListener(onTabCloseListener); + const { stepName, details: err } = message.payload; + reject( + new ErrorWithKey( + 'connectWalletKeyService_error_failed', + [ + stepName, + isErrorWithKey(err.error) ? this.t(err.error) : err.message, + ], + isErrorWithKey(err.error) ? err.error : undefined, + ), + ); + } else if (message.action === 'PROGRESS') { + // can also save progress to show in popup + for (const p of ports) { + if (p !== port) p.postMessage(message); + } + } else { + reject(new Error(`Unexpected message: ${JSON.stringify(message)}`)); + } + }; + + this.browser.runtime.onConnect.addListener(onConnectListener); + + return promise; + } + + private async validate(walletAddressUrl: string, keyId: string) { + type JWKS = { keys: { kid: string }[] }; + const jwksUrl = new URL('jwks.json', ensureEnd(walletAddressUrl, '/')); + const res = await fetch(jwksUrl.toString()); + const jwks: JWKS = await res.json(); + if (!jwks.keys.find((key) => key.kid === keyId)) { + throw new Error('Key not found in jwks'); + } + } + + private updateConnectState(err?: ErrorWithKeyLike | { message: string }) { + if (err) { + this.storage.setPopupTransientState('connect', () => ({ + status: 'error:key', + error: isErrorWithKey(err) ? errorWithKeyToJSON(err) : err.message, + })); + } else { + this.storage.setPopupTransientState('connect', () => ({ + status: 'connecting:key', + })); + } + } + + static supports(walletAddress: WalletAddress): boolean { + try { + walletAddressToProvider(walletAddress); + return true; + } catch { + return false; + } + } +} + +export function walletAddressToProvider(walletAddress: WalletAddress): { + url: string; +} { + const { host } = new URL(walletAddress.id); + switch (host) { + case 'ilp.rafiki.money': + return { url: 'https://rafiki.money/settings/developer-keys' }; + // case 'eu1.fynbos.me': // fynbos dev + // case 'fynbos.me': // fynbos production + default: + throw new ErrorWithKey('connectWalletKeyService_error_notImplemented'); + } +} diff --git a/src/background/services/monetization.ts b/src/background/services/monetization.ts index 6b268986..c2c578d2 100644 --- a/src/background/services/monetization.ts +++ b/src/background/services/monetization.ts @@ -1,26 +1,26 @@ -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 { emitToggleWM } from '../lib/messages' -import { computeRate, getCurrentActiveTab, 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' + StopMonetizationPayload, +} from '@/shared/messages'; +import { PaymentSession } from './paymentSession'; +import { computeRate, getSender, getTabId } from '../utils'; +import { isOutOfBalanceError } from './openPayments'; +import { isOkState, removeQueryParams } from '@/shared/helpers'; +import type { AmountValue, PopupStore, Storage } from '@/shared/types'; +import type { Cradle } from '../container'; export class MonetizationService { - private logger: Cradle['logger'] - private t: Cradle['t'] - private openPaymentsService: Cradle['openPaymentsService'] - private storage: Cradle['storage'] - private browser: Cradle['browser'] - private events: Cradle['events'] - private tabState: Cradle['tabState'] + private logger: Cradle['logger']; + private t: Cradle['t']; + private openPaymentsService: Cradle['openPaymentsService']; + private storage: Cradle['storage']; + private browser: Cradle['browser']; + private events: Cradle['events']; + private tabState: Cradle['tabState']; + private windowState: Cradle['windowState']; + private message: Cradle['message']; constructor({ logger, @@ -29,7 +29,9 @@ export class MonetizationService { storage, events, openPaymentsService, - tabState + tabState, + windowState, + message, }: Cradle) { Object.assign(this, { logger, @@ -38,54 +40,56 @@ export class MonetizationService { storage, browser, events, - tabState - }) + tabState, + windowState, + message, + }); - this.registerEventListeners() + this.registerEventListeners(); } async startPaymentSession( - payload: StartMonetizationPayload[], - sender: Runtime.MessageSender + payload: StartMonetizationPayload, + sender: Runtime.MessageSender, ) { if (!payload.length) { - throw new Error('Unexpected: payload is empty') + throw new Error('Unexpected: payload is empty'); } const { state, enabled, rateOfPay, connected, - walletAddress: connectedWallet + walletAddress: connectedWallet, } = await this.storage.get([ 'state', 'enabled', 'connected', 'rateOfPay', - 'walletAddress' - ]) + 'walletAddress', + ]); if (!rateOfPay || !connectedWallet) { this.logger.error( - `Did not find rate of pay or connect wallet information. Received rate=${rateOfPay}, wallet=${connectedWallet}. Payment session will not be initialized.` - ) - return + `Did not find rate of pay or connect wallet information. Received rate=${rateOfPay}, wallet=${connectedWallet}. Payment session will not be initialized.`, + ); + return; } - const { tabId, frameId, url } = getSender(sender) - const sessions = this.tabState.getSessions(tabId) + const { tabId, frameId, url } = getSender(sender); + const sessions = this.tabState.getSessions(tabId); - const replacedSessions = new Set() + const replacedSessions = new Set(); // Initialize new sessions payload.forEach((p) => { - const { requestId, walletAddress: receiver } = p + const { requestId, walletAddress: receiver } = p; // Q: How does this impact client side apps/routing? - const existingSession = sessions.get(requestId) + const existingSession = sessions.get(requestId); if (existingSession) { - existingSession.stop() - sessions.delete(requestId) - replacedSessions.add(requestId) + existingSession.stop(); + sessions.delete(requestId); + replacedSessions.add(requestId); } const session = new PaymentSession( @@ -98,267 +102,273 @@ export class MonetizationService { this.events, this.tabState, removeQueryParams(url!), - this.logger - ) + this.logger, + this.message, + ); - sessions.set(requestId, session) - }) + sessions.set(requestId, session); + }); - this.events.emit('monetization.state_update', tabId) + this.events.emit('monetization.state_update', tabId); - const sessionsArr = this.tabState.getPayableSessions(tabId) - if (!sessionsArr.length) return - const rate = computeRate(rateOfPay, sessionsArr.length) + const sessionsArr = this.tabState.getPayableSessions(tabId); + if (!sessionsArr.length) return; + const rate = computeRate(rateOfPay, sessionsArr.length); // Since we probe (through quoting) the debitAmount we have to await this call. - const isAdjusted = await this.adjustSessionsAmount(sessionsArr, rate) - if (!isAdjusted) return + const isAdjusted = await this.adjustSessionsAmount(sessionsArr, rate); + if (!isAdjusted) return; if (enabled && this.canTryPayment(connected, state)) { sessionsArr.forEach((session) => { - if (!sessions.get(session.id)) return + if (!sessions.get(session.id)) return; const source = replacedSessions.has(session.id) ? 'request-id-reused' - : 'new-link' - void session.start(source) - }) + : 'new-link'; + void session.start(source); + }); } } async stopPaymentSessionsByTabId(tabId: number) { - const sessions = this.tabState.getSessions(tabId) + const sessions = this.tabState.getSessions(tabId); if (!sessions.size) { - this.logger.debug(`No active sessions found for tab ${tabId}.`) - return + this.logger.debug(`No active sessions found for tab ${tabId}.`); + return; } for (const session of sessions.values()) { - session.stop() + session.stop(); } } async stopPaymentSession( - payload: StopMonetizationPayload[], - sender: Runtime.MessageSender + payload: StopMonetizationPayload, + sender: Runtime.MessageSender, ) { - let needsAdjustAmount = false - const tabId = getTabId(sender) - const sessions = this.tabState.getSessions(tabId) + let needsAdjustAmount = false; + const tabId = getTabId(sender); + const sessions = this.tabState.getSessions(tabId); if (!sessions.size) { - this.logger.debug(`No active sessions found for tab ${tabId}.`) - return + this.logger.debug(`No active sessions found for tab ${tabId}.`); + return; } payload.forEach((p) => { - const { requestId } = p + const { requestId } = p; - const session = sessions.get(requestId) - if (!session) return + const session = sessions.get(requestId); + if (!session) return; if (p.intent === 'remove') { - needsAdjustAmount = true - session.stop() - sessions.delete(requestId) + needsAdjustAmount = true; + session.stop(); + sessions.delete(requestId); } else if (p.intent === 'disable') { - needsAdjustAmount = true - session.disable() + needsAdjustAmount = true; + session.disable(); } else { - session.stop() + session.stop(); } - }) + }); - const { rateOfPay } = await this.storage.get(['rateOfPay']) - if (!rateOfPay) return + const { rateOfPay } = await this.storage.get(['rateOfPay']); + if (!rateOfPay) return; if (needsAdjustAmount) { - const sessionsArr = this.tabState.getPayableSessions(tabId) - this.events.emit('monetization.state_update', tabId) - if (!sessionsArr.length) return - const rate = computeRate(rateOfPay, sessionsArr.length) + const sessionsArr = this.tabState.getPayableSessions(tabId); + this.events.emit('monetization.state_update', tabId); + if (!sessionsArr.length) return; + const rate = computeRate(rateOfPay, sessionsArr.length); await this.adjustSessionsAmount(sessionsArr, rate).catch((e) => { - this.logger.error(e) - }) + this.logger.error(e); + }); } } async resumePaymentSession( - payload: ResumeMonetizationPayload[], - sender: Runtime.MessageSender + payload: ResumeMonetizationPayload, + sender: Runtime.MessageSender, ) { - const tabId = getTabId(sender) - const sessions = this.tabState.getSessions(tabId) + const tabId = getTabId(sender); + const sessions = this.tabState.getSessions(tabId); if (!sessions.size) { - this.logger.debug(`No active sessions found for tab ${tabId}.`) - return + this.logger.debug(`No active sessions found for tab ${tabId}.`); + return; } const { state, connected, enabled } = await this.storage.get([ 'state', 'connected', - 'enabled' - ]) - if (!enabled || !this.canTryPayment(connected, state)) return + 'enabled', + ]); + if (!enabled || !this.canTryPayment(connected, state)) return; payload.forEach((p) => { - const { requestId } = p + const { requestId } = p; - sessions.get(requestId)?.resume() - }) + sessions.get(requestId)?.resume(); + }); } async resumePaymentSessionsByTabId(tabId: number) { - const sessions = this.tabState.getSessions(tabId) + const sessions = this.tabState.getSessions(tabId); if (!sessions.size) { - this.logger.debug(`No active sessions found for tab ${tabId}.`) - return + this.logger.debug(`No active sessions found for tab ${tabId}.`); + return; } const { state, connected, enabled } = await this.storage.get([ 'state', 'connected', - 'enabled' - ]) - if (!enabled || !this.canTryPayment(connected, state)) return + 'enabled', + ]); + if (!enabled || !this.canTryPayment(connected, state)) return; for (const session of sessions.values()) { - session.resume() + session.resume(); } } async resumePaymentSessionActiveTab() { - const currentTab = await getCurrentActiveTab(this.browser) - if (!currentTab?.id) return - await this.resumePaymentSessionsByTabId(currentTab.id) + const currentTab = await this.windowState.getCurrentTab(); + if (!currentTab?.id) return; + await this.resumePaymentSessionsByTabId(currentTab.id); } async toggleWM() { - const { enabled } = await this.storage.get(['enabled']) - await this.storage.set({ enabled: !enabled }) - emitToggleWM({ enabled: !enabled }) + const { enabled } = await this.storage.get(['enabled']); + const nowEnabled = !enabled; + await this.storage.set({ enabled: nowEnabled }); + if (nowEnabled) { + await this.resumePaymentSessionActiveTab(); + } else { + this.stopAllSessions(); + } } 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.') + throw new Error('Unexpected error: could not find active tab.'); } - const payableSessions = this.tabState.getPayableSessions(tab.id) + const payableSessions = this.tabState.getPayableSessions(tab.id); if (!payableSessions.length) { if (this.tabState.getEnabledSessions(tab.id).length) { - throw new Error(this.t('pay_error_invalidReceivers')) + throw new Error(this.t('pay_error_invalidReceivers')); } - throw new Error(this.t('pay_error_notMonetized')) + throw new Error(this.t('pay_error_notMonetized')); } - const splitAmount = Number(amount) / payableSessions.length + const splitAmount = Number(amount) / payableSessions.length; // TODO: handle paying across two grants (when one grant doesn't have enough funds) const results = await Promise.allSettled( - payableSessions.map((session) => session.pay(splitAmount)) - ) + payableSessions.map((session) => session.pay(splitAmount)), + ); const totalSentAmount = results .filter((e) => e.status === 'fulfilled') - .reduce((acc, curr) => acc + BigInt(curr.value?.value ?? 0), 0n) + .reduce((acc, curr) => acc + BigInt(curr.value?.value ?? 0), 0n); if (totalSentAmount === 0n) { const isNotEnoughFunds = results .filter((e) => e.status === 'rejected') - .some((e) => isOutOfBalanceError(e.reason)) + .some((e) => isOutOfBalanceError(e.reason)); if (isNotEnoughFunds) { - throw new Error(this.t('pay_error_notEnoughFunds')) + throw new Error(this.t('pay_error_notEnoughFunds')); } - throw new Error('Could not facilitate payment for current website.') + throw new Error('Could not facilitate payment for current website.'); } } private canTryPayment( connected: Storage['connected'], - state: Storage['state'] + state: Storage['state'], ): boolean { - if (!connected) return false - if (isOkState(state)) return true + if (!connected) return false; + if (isOkState(state)) return true; if (state.out_of_funds && this.openPaymentsService.isAnyGrantUsable()) { // if we're in out_of_funds state, we still try to make payments hoping we // have funds available now. If a payment succeeds, we move out from // of_out_funds state. - return true + return true; } - return false + return false; } private registerEventListeners() { - this.onRateOfPayUpdate() - this.onKeyRevoked() - this.onOutOfFunds() - this.onInvalidReceiver() + this.onRateOfPayUpdate(); + this.onKeyRevoked(); + this.onOutOfFunds(); + this.onInvalidReceiver(); } private onRateOfPayUpdate() { this.events.on('storage.rate_of_pay_update', async ({ rate }) => { - this.logger.debug("Received event='storage.rate_of_pay_update'") - const tabIds = this.tabState.getAllTabs() + this.logger.debug("Received event='storage.rate_of_pay_update'"); + 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) + const idx = tabIds.indexOf(currentTab.id); if (idx !== -1) { - const tmp = tabIds[0] - tabIds[0] = currentTab.id - tabIds[idx] = tmp + const tmp = tabIds[0]; + tabIds[0] = currentTab.id; + tabIds[idx] = tmp; } } for (const tabId of tabIds) { - const sessions = this.tabState.getPayableSessions(tabId) - if (!sessions.length) continue - const computedRate = computeRate(rate, sessions.length) + const sessions = this.tabState.getPayableSessions(tabId); + if (!sessions.length) continue; + const computedRate = computeRate(rate, sessions.length); await this.adjustSessionsAmount(sessions, computedRate).catch((e) => { - this.logger.error(e) - }) + this.logger.error(e); + }); } - }) + }); } private onKeyRevoked() { this.events.once('open_payments.key_revoked', async () => { - this.logger.warn(`Key revoked. Stopping all payment sessions.`) - this.stopAllSessions() - await this.storage.setState({ key_revoked: true }) - this.onKeyRevoked() // setup listener again once all is done - }) + this.logger.warn(`Key revoked. Stopping all payment sessions.`); + this.stopAllSessions(); + await this.storage.setState({ key_revoked: true }); + this.onKeyRevoked(); // setup listener again once all is done + }); } private onOutOfFunds() { this.events.once('open_payments.out_of_funds', async () => { - this.logger.warn(`Out of funds. Stopping all payment sessions.`) - this.stopAllSessions() - await this.storage.setState({ out_of_funds: true }) - this.onOutOfFunds() // setup listener again once all is done - }) + this.logger.warn(`Out of funds. Stopping all payment sessions.`); + this.stopAllSessions(); + await this.storage.setState({ out_of_funds: true }); + this.onOutOfFunds(); // setup listener again once all is done + }); } private onInvalidReceiver() { this.events.on('open_payments.invalid_receiver', async ({ tabId }) => { if (this.tabState.tabHasAllSessionsInvalid(tabId)) { - this.logger.debug(`Tab ${tabId} has all sessions invalid`) - this.events.emit('monetization.state_update', tabId) + this.logger.debug(`Tab ${tabId} has all sessions invalid`); + this.events.emit('monetization.state_update', tabId); } - }) + }); } private stopAllSessions() { for (const session of this.tabState.getAllSessions()) { - session.stop() + session.stop(); } - this.logger.debug(`All payment sessions stopped.`) + this.logger.debug(`All payment sessions stopped.`); } - async getPopupData(): Promise { + async getPopupData(tab: Pick): Promise { const storedData = await this.storage.get([ 'enabled', 'connected', @@ -369,56 +379,37 @@ export class MonetizationService { 'walletAddress', 'oneTimeGrant', 'recurringGrant', - '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! - ) + 'publicKey', + ]); + const balance = await this.storage.getBalance(); + + const { oneTimeGrant, recurringGrant, ...dataFromStorage } = storedData; return { ...dataFromStorage, balance: balance.total.toString(), - url, + tab: this.tabState.getPopupTabData(tab), + transientState: this.storage.getPopupTransientState(), grants: { oneTime: oneTimeGrant?.amount, - recurring: recurringGrant?.amount + recurring: recurringGrant?.amount, }, - isSiteMonetized, - hasAllSessionsInvalid - } + }; } private async adjustSessionsAmount( sessions: PaymentSession[], - rate: AmountValue + rate: AmountValue, ): Promise { try { - await Promise.all(sessions.map((session) => session.adjustAmount(rate))) - return true + await Promise.all(sessions.map((session) => session.adjustAmount(rate))); + return true; } catch (err) { if (err.name === 'AbortError') { - this.logger.debug('adjustAmount aborted due to new call') - return false + this.logger.debug('adjustAmount aborted due to new call'); + return false; } else { - throw err + throw err; } } } diff --git a/src/background/services/openPayments.ts b/src/background/services/openPayments.ts index 12382020..d7a23725 100644 --- a/src/background/services/openPayments.ts +++ b/src/background/services/openPayments.ts @@ -3,145 +3,173 @@ import type { AccessToken, AmountValue, GrantDetails, - WalletAmount -} from 'shared/types' + TabId, + WalletAmount, +} from 'shared/types'; import { type AuthenticatedClient, createAuthenticatedClient, - OpenPaymentsClientError -} from '@interledger/open-payments/dist/client' + OpenPaymentsClientError, +} from '@interledger/open-payments/dist/client'; import { isFinalizedGrant, isPendingGrant, type IncomingPayment, type OutgoingPaymentWithSpentAmounts as OutgoingPayment, - type WalletAddress -} from '@interledger/open-payments/dist/types' -import * as ed from '@noble/ed25519' -import { type Request } from 'http-message-signatures' -import { signMessage } from 'http-message-signatures/lib/httpbis' -import { createContentDigestHeader } from 'httpbis-digest-headers' -import type { Tabs } from 'webextension-polyfill' -import { getExchangeRates, getRateOfPay, toAmount } from '../utils' -import { exportJWK, generateEd25519KeyPair } from '@/shared/crypto' -import { bytesToHex } from '@noble/hashes/utils' -import { getWalletInformation } from '@/shared/helpers' -import { AddFundsPayload, ConnectWalletPayload } from '@/shared/messages' + type WalletAddress, +} from '@interledger/open-payments/dist/types'; +import * as ed from '@noble/ed25519'; +import { type Request } from 'http-message-signatures'; +import { signMessage } from 'http-message-signatures/lib/httpbis'; +import { createContentDigestHeader } from 'httpbis-digest-headers'; +import type { Browser, Tabs } from 'webextension-polyfill'; +import { getExchangeRates, getRateOfPay, toAmount } from '../utils'; +import { KeyAutoAddService } from './keyAutoAdd'; +import { exportJWK, generateEd25519KeyPair } from '@/shared/crypto'; +import { bytesToHex } from '@noble/hashes/utils'; +import { + ErrorWithKey, + errorWithKeyToJSON, + getWalletInformation, + isErrorWithKey, + withResolvers, + type ErrorWithKeyLike, +} from '@/shared/helpers'; +import type { AddFundsPayload, ConnectWalletPayload } from '@/shared/messages'; import { DEFAULT_RATE_OF_PAY, MAX_RATE_OF_PAY, - MIN_RATE_OF_PAY -} from '../config' -import { OPEN_PAYMENTS_REDIRECT_URL } from '@/shared/defines' -import type { Cradle } from '../container' + MIN_RATE_OF_PAY, +} from '../config'; +import { OPEN_PAYMENTS_REDIRECT_URL } from '@/shared/defines'; +import type { Cradle } from '@/background/container'; interface KeyInformation { - privateKey: string - keyId: string + privateKey: string; + keyId: string; } interface InteractionParams { - interactRef: string - hash: string - tabId: NonNullable + interactRef: string; + hash: string; + tabId: TabId; } export interface SignatureHeaders { - Signature: string - 'Signature-Input': string + Signature: string; + 'Signature-Input': string; } interface ContentHeaders { - 'Content-Digest': string - 'Content-Length': string - 'Content-Type': string + 'Content-Digest': string; + 'Content-Length': string; + 'Content-Type': string; } -type Headers = SignatureHeaders & Partial +type Headers = SignatureHeaders & Partial; interface RequestLike extends Request { - body?: string + body?: string; } interface SignOptions { - request: RequestLike - privateKey: Uint8Array - keyId: string + request: RequestLike; + privateKey: Uint8Array; + keyId: string; } interface VerifyInteractionHashParams { - clientNonce: string - interactRef: string - interactNonce: string - hash: string - authServer: string + clientNonce: string; + interactRef: string; + interactNonce: string; + hash: string; + authServer: string; } interface CreateOutgoingPaymentGrantParams { - clientNonce: string - walletAddress: WalletAddress - amount: WalletAmount + clientNonce: string; + walletAddress: WalletAddress; + amount: WalletAmount; } interface CreateOutgoingPaymentParams { - walletAddress: WalletAddress - incomingPaymentId: IncomingPayment['id'] - amount: string + walletAddress: WalletAddress; + incomingPaymentId: IncomingPayment['id']; + amount: string; } -type TabUpdateCallback = Parameters[0] +type TabUpdateCallback = Parameters[0]; +type TabRemovedCallback = Parameters< + Browser['tabs']['onRemoved']['addListener'] +>[0]; const enum ErrorCode { CONTINUATION_FAILED = 'continuation_failed', - HASH_FAILED = 'hash_failed' + HASH_FAILED = 'hash_failed', + KEY_ADD_FAILED = 'key_add_failed', } const enum GrantResult { SUCCESS = 'grant_success', - ERROR = 'grant_error' + ERROR = 'grant_error', } const enum InteractionIntent { CONNECT = 'connect', - FUNDS = 'funds' + FUNDS = 'funds', } export class OpenPaymentsService { - private browser: Cradle['browser'] - private storage: Cradle['storage'] - private deduplicator: Cradle['deduplicator'] - private logger: Cradle['logger'] - private t: Cradle['t'] + private browser: Cradle['browser']; + private storage: Cradle['storage']; + private deduplicator: Cradle['deduplicator']; + private logger: Cradle['logger']; + private browserName: Cradle['browserName']; + private t: Cradle['t']; - client?: AuthenticatedClient + client?: AuthenticatedClient; - public switchGrant: OpenPaymentsService['_switchGrant'] + public switchGrant: OpenPaymentsService['_switchGrant']; - private token: AccessToken - private grantDetails: GrantDetails | null + private token: AccessToken; + private grantDetails: GrantDetails | null; /** Whether a grant has enough balance to make payments */ - private isGrantUsable = { recurring: false, oneTime: false } - - constructor({ browser, storage, deduplicator, logger, t }: Cradle) { - Object.assign(this, { browser, storage, deduplicator, logger, t }) - - void this.initialize() - this.switchGrant = this.deduplicator.dedupe(this._switchGrant.bind(this)) + private isGrantUsable = { recurring: false, oneTime: false }; + + constructor({ + browser, + storage, + deduplicator, + logger, + t, + browserName, + }: Cradle) { + Object.assign(this, { + browser, + storage, + deduplicator, + logger, + t, + browserName, + }); + + void this.initialize(); + this.switchGrant = this.deduplicator.dedupe(this._switchGrant.bind(this)); } public isAnyGrantUsable() { - return this.isGrantUsable.recurring || this.isGrantUsable.oneTime + return this.isGrantUsable.recurring || this.isGrantUsable.oneTime; } private get grant() { - return this.grantDetails + return this.grantDetails; } private set grant(grantDetails) { - this.logger.debug(`🤝🏻 Using grant: ${grantDetails?.type || null}`) - this.grantDetails = grantDetails + this.logger.debug(`🤝🏻 Using grant: ${grantDetails?.type || null}`); + this.grantDetails = grantDetails; this.token = grantDetails ? grantDetails.accessToken - : { value: '', manageUrl: '' } + : { value: '', manageUrl: '' }; } private async initialize() { @@ -150,43 +178,43 @@ export class OpenPaymentsService { 'connected', 'walletAddress', 'oneTimeGrant', - 'recurringGrant' - ]) + 'recurringGrant', + ]); - this.isGrantUsable.recurring = !!recurringGrant - this.isGrantUsable.oneTime = !!oneTimeGrant + this.isGrantUsable.recurring = !!recurringGrant; + this.isGrantUsable.oneTime = !!oneTimeGrant; if ( connected === true && walletAddress && (recurringGrant || oneTimeGrant) ) { - this.grant = recurringGrant || oneTimeGrant! // prefer recurring - await this.initClient(walletAddress.id) + this.grant = recurringGrant || oneTimeGrant!; // prefer recurring + await this.initClient(walletAddress.id); } } private async getPrivateKeyInformation(): Promise { - const data = await this.browser.storage.local.get(['privateKey', 'keyId']) + const data = await this.browser.storage.local.get(['privateKey', 'keyId']); if (data.privateKey && data.keyId) { - return data as unknown as KeyInformation + return data as unknown as KeyInformation; } throw new Error( - 'Could not create OpenPayments client. Missing `privateKey` and `keyId`.' - ) + 'Could not create OpenPayments client. Missing `privateKey` and `keyId`.', + ); } private createContentHeaders(body: string): ContentHeaders { return { 'Content-Digest': createContentDigestHeader( JSON.stringify(JSON.parse(body)), - ['sha-512'] + ['sha-512'], ), 'Content-Length': new TextEncoder().encode(body).length.toString(), - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }; } private createSigner(key: Uint8Array, keyId: string) { @@ -194,70 +222,70 @@ export class OpenPaymentsService { id: keyId, alg: 'ed25519', async sign(data: Uint8Array) { - return Buffer.from(await ed.signAsync(data, key.slice(16))) - } - } + return Buffer.from(await ed.signAsync(data, key.slice(16))); + }, + }; } private async createSignatureHeaders({ request, privateKey, - keyId + keyId, }: SignOptions): Promise { - const components = ['@method', '@target-uri'] + const components = ['@method', '@target-uri']; if (request.headers['Authorization'] || request.headers['authorization']) { - components.push('authorization') + components.push('authorization'); } if (request.body) { - components.push('content-digest', 'content-length', 'content-type') + components.push('content-digest', 'content-length', 'content-type'); } - const signingKey = this.createSigner(privateKey, keyId) + const signingKey = this.createSigner(privateKey, keyId); const { headers } = await signMessage( { name: 'sig1', params: ['keyid', 'created'], fields: components, - key: signingKey + key: signingKey, }, { url: request.url, method: request.method, - headers: request.headers - } - ) + headers: request.headers, + }, + ); return { Signature: headers['Signature'] as string, - 'Signature-Input': headers['Signature-Input'] as string - } + 'Signature-Input': headers['Signature-Input'] as string, + }; } private async createHeaders({ request, privateKey, - keyId + keyId, }: SignOptions): Promise { if (request.body) { - const contentHeaders = this.createContentHeaders(request.body) - request.headers = { ...request.headers, ...contentHeaders } + const contentHeaders = this.createContentHeaders(request.body); + request.headers = { ...request.headers, ...contentHeaders }; } const signatureHeaders = await this.createSignatureHeaders({ request, privateKey, - keyId - }) + keyId, + }); return { ...request.headers, - ...signatureHeaders - } + ...signatureHeaders, + }; } async initClient(walletAddressUrl: string) { - const { privateKey, keyId } = await this.getPrivateKeyInformation() + const { privateKey, keyId } = await this.getPrivateKeyInformation(); this.client = await createAuthenticatedClient({ validateResponses: false, @@ -265,177 +293,244 @@ export class OpenPaymentsService { walletAddressUrl, authenticatedRequestInterceptor: async (request) => { if (!request.method || !request.url) { - throw new Error('Cannot intercept request: url or method missing') + throw new Error('Cannot intercept request: url or method missing'); } - const initialRequest = request.clone() + const initialRequest = request.clone(); const headers = await this.createHeaders({ request: { method: request.method, url: request.url, headers: JSON.parse( - JSON.stringify(Object.fromEntries(request.headers)) + JSON.stringify(Object.fromEntries(request.headers)), ), body: request.body ? JSON.stringify(await request.json()) - : undefined + : undefined, }, privateKey: ed.etc.hexToBytes(privateKey), - keyId - }) + keyId, + }); if (request.body) { initialRequest.headers.set( 'Content-Type', - headers['Content-Type'] as string - ) + headers['Content-Type'] as string, + ); initialRequest.headers.set( 'Content-Digest', - headers['Content-Digest'] as string - ) + headers['Content-Digest'] as string, + ); } - initialRequest.headers.set('Signature', headers['Signature']) + initialRequest.headers.set('Signature', headers['Signature']); initialRequest.headers.set( 'Signature-Input', - headers['Signature-Input'] - ) + headers['Signature-Input'], + ); - return initialRequest - } - }) + return initialRequest; + }, + }); } - async connectWallet({ - walletAddressUrl, - amount, - recurring - }: ConnectWalletPayload) { - const walletAddress = await getWalletInformation(walletAddressUrl) - const exchangeRates = await getExchangeRates() + async connectWallet(params: ConnectWalletPayload | null) { + if (!params) { + this.setConnectState(null); + return; + } + const { + walletAddressUrl, + amount, + recurring, + autoKeyAdd, + autoKeyAddConsent, + } = params; + + const walletAddress = await getWalletInformation(walletAddressUrl); + const exchangeRates = await getExchangeRates(); - let rateOfPay = DEFAULT_RATE_OF_PAY - let minRateOfPay = MIN_RATE_OF_PAY - let maxRateOfPay = MAX_RATE_OF_PAY + let rateOfPay = DEFAULT_RATE_OF_PAY; + let minRateOfPay = MIN_RATE_OF_PAY; + let maxRateOfPay = MAX_RATE_OF_PAY; if (!exchangeRates.rates[walletAddress.assetCode]) { - throw new Error(`Exchange rate for ${walletAddress.assetCode} not found.`) + throw new Error( + `Exchange rate for ${walletAddress.assetCode} not found.`, + ); } - const exchangeRate = exchangeRates.rates[walletAddress.assetCode] + const exchangeRate = exchangeRates.rates[walletAddress.assetCode]; rateOfPay = getRateOfPay({ rate: DEFAULT_RATE_OF_PAY, exchangeRate, - assetScale: walletAddress.assetScale - }) + assetScale: walletAddress.assetScale, + }); minRateOfPay = getRateOfPay({ rate: MIN_RATE_OF_PAY, exchangeRate, - assetScale: walletAddress.assetScale - }) + assetScale: walletAddress.assetScale, + }); maxRateOfPay = getRateOfPay({ rate: MAX_RATE_OF_PAY, exchangeRate, - assetScale: walletAddress.assetScale - }) + assetScale: walletAddress.assetScale, + }); - await this.initClient(walletAddress.id) - await this.completeGrant(amount, walletAddress, recurring) + await this.initClient(walletAddress.id); + this.setConnectState('connecting'); + try { + await this.completeGrant( + amount, + walletAddress, + recurring, + InteractionIntent.CONNECT, + ); + } catch (error) { + if ( + isErrorWithKey(error) && + error.key === 'connectWallet_error_invalidClient' && + autoKeyAdd + ) { + if (!KeyAutoAddService.supports(walletAddress)) { + this.updateConnectStateError(error); + throw new ErrorWithKey( + 'connectWalletKeyService_error_notImplemented', + ); + } + if (!autoKeyAddConsent) { + this.updateConnectStateError(error); + throw new ErrorWithKey('connectWalletKeyService_error_noConsent'); + } + + // add key to wallet and try again + try { + const tabId = await this.addPublicKeyToWallet(walletAddress); + this.setConnectState('connecting'); + await this.completeGrant( + amount, + walletAddress, + recurring, + InteractionIntent.CONNECT, + tabId, + ); + } catch (error) { + this.updateConnectStateError(error); + throw error; + } + } else { + this.updateConnectStateError(error); + throw error; + } + } await this.storage.set({ walletAddress, rateOfPay, minRateOfPay, maxRateOfPay, - connected: true - }) + connected: true, + }); + this.setConnectState(null); } async addFunds({ amount, recurring }: AddFundsPayload) { const { walletAddress, ...grants } = await this.storage.get([ 'walletAddress', 'oneTimeGrant', - 'recurringGrant' - ]) + 'recurringGrant', + ]); - await this.completeGrant(amount, walletAddress!, recurring) + await this.completeGrant( + amount, + walletAddress!, + recurring, + InteractionIntent.FUNDS, + ); // cancel existing grants of same type, if any if (grants.oneTimeGrant && !recurring) { - await this.cancelGrant(grants.oneTimeGrant.continue) + await this.cancelGrant(grants.oneTimeGrant.continue); } else if (grants.recurringGrant && recurring) { - await this.cancelGrant(grants.recurringGrant.continue) + await this.cancelGrant(grants.recurringGrant.continue); } - await this.storage.setState({ out_of_funds: false }) + await this.storage.setState({ out_of_funds: false }); } private async completeGrant( amount: string, walletAddress: WalletAddress, recurring: boolean, - intent: InteractionIntent = InteractionIntent.CONNECT + intent: InteractionIntent, + existingTabId?: TabId, ): Promise { const transformedAmount = toAmount({ value: amount, recurring, - assetScale: walletAddress.assetScale - }) + assetScale: walletAddress.assetScale, + }); - const clientNonce = crypto.randomUUID() + const clientNonce = crypto.randomUUID(); const grant = await this.createOutgoingPaymentGrant({ clientNonce, walletAddress, - amount: transformedAmount + amount: transformedAmount, }).catch((err) => { if (isInvalidClientError(err)) { - const msg = this.t('connectWallet_error_invalidClient') - throw new Error(msg, { cause: err }) + if (intent === InteractionIntent.CONNECT) { + throw new ErrorWithKey('connectWallet_error_invalidClient'); + } + const msg = this.t('connectWallet_error_invalidClient'); + throw new Error(msg, { cause: err }); } - throw err - }) + throw err; + }); const { interactRef, hash, tabId } = await this.getInteractionInfo( - grant.interact.redirect - ) + grant.interact.redirect, + existingTabId, + ); await this.verifyInteractionHash({ clientNonce, interactNonce: grant.interact.finish, interactRef, hash, - authServer: walletAddress.authServer + authServer: walletAddress.authServer, }).catch(async (e) => { await this.redirectToWelcomeScreen( tabId, GrantResult.ERROR, intent, - ErrorCode.HASH_FAILED - ) - throw e - }) + ErrorCode.HASH_FAILED, + ); + throw e; + }); const continuation = await this.client!.grant.continue( { url: grant.continue.uri, - accessToken: grant.continue.access_token.value + accessToken: grant.continue.access_token.value, }, { - interact_ref: interactRef - } + interact_ref: interactRef, + }, ).catch(async (e) => { await this.redirectToWelcomeScreen( tabId, GrantResult.ERROR, intent, - ErrorCode.CONTINUATION_FAILED - ) - throw e - }) + ErrorCode.CONTINUATION_FAILED, + ); + throw e; + }); if (!isFinalizedGrant(continuation)) { - throw new Error('Expected finalized grant. Received non-finalized grant.') + throw new Error( + 'Expected finalized grant. Received non-finalized grant.', + ); } const grantDetails: GrantDetails = { @@ -443,63 +538,117 @@ export class OpenPaymentsService { amount: transformedAmount as Required, accessToken: { value: continuation.access_token.value, - manageUrl: continuation.access_token.manage + manageUrl: continuation.access_token.manage, }, continue: { accessToken: continuation.continue.access_token.value, - url: continuation.continue.uri - } - } + url: continuation.continue.uri, + }, + }; if (grantDetails.type === 'recurring') { await this.storage.set({ recurringGrant: grantDetails, - recurringGrantSpentAmount: '0' - }) - this.isGrantUsable.recurring = true + recurringGrantSpentAmount: '0', + }); + this.isGrantUsable.recurring = true; } else { await this.storage.set({ oneTimeGrant: grantDetails, - oneTimeGrantSpentAmount: '0' - }) - this.isGrantUsable.oneTime = true + oneTimeGrantSpentAmount: '0', + }); + this.isGrantUsable.oneTime = true; } - this.grant = grantDetails - await this.redirectToWelcomeScreen(tabId, GrantResult.SUCCESS, intent) - return grantDetails + this.grant = grantDetails; + await this.redirectToWelcomeScreen(tabId, GrantResult.SUCCESS, intent); + return grantDetails; + } + + /** + * Adds public key to wallet by "browser automation" - the content script + * takes control of tab when the correct message is sent, and adds the key + * through the wallet's dashboard. + * @returns tabId that we can reuse for further connecting, or redirects etc. + */ + private async addPublicKeyToWallet( + walletAddress: WalletAddress, + ): Promise { + const keyAutoAdd = new KeyAutoAddService({ + browser: this.browser, + storage: this.storage, + browserName: this.browserName, + t: this.t, + }); + try { + await keyAutoAdd.addPublicKeyToWallet(walletAddress); + return keyAutoAdd.tabId; + } catch (error) { + const tabId = keyAutoAdd.tabId; + const isTabClosed = error.key === 'connectWallet_error_tabClosed'; + if (tabId && !isTabClosed) { + await this.redirectToWelcomeScreen( + tabId, + GrantResult.ERROR, + InteractionIntent.CONNECT, + ErrorCode.KEY_ADD_FAILED, + ); + } + if (error instanceof ErrorWithKey) { + throw error; + } else { + // TODO: check if need to handle errors here + throw new Error(error.message, { cause: error }); + } + } + } + + private setConnectState(status: 'connecting' | null) { + const state = status ? { status } : null; + this.storage.setPopupTransientState('connect', () => state); + } + private updateConnectStateError(err: ErrorWithKeyLike | { message: string }) { + this.storage.setPopupTransientState('connect', (state) => { + if (state?.status === 'error:key') { + return state; + } + return { + status: 'error', + error: isErrorWithKey(err) ? errorWithKeyToJSON(err) : err.message, + }; + }); } private async redirectToWelcomeScreen( tabId: NonNullable, result: GrantResult, intent: InteractionIntent, - errorCode?: ErrorCode + errorCode?: ErrorCode, ) { - const url = new URL(OPEN_PAYMENTS_REDIRECT_URL) - url.searchParams.set('result', result) - url.searchParams.set('intent', intent) - if (errorCode) url.searchParams.set('errorCode', errorCode) + const url = new URL(OPEN_PAYMENTS_REDIRECT_URL); + url.searchParams.set('result', result); + url.searchParams.set('intent', intent); + if (errorCode) url.searchParams.set('errorCode', errorCode); await this.browser.tabs.update(tabId, { - url: url.toString() - }) + url: url.toString(), + }); } private async createOutgoingPaymentGrant({ amount, walletAddress, - clientNonce + clientNonce, }: CreateOutgoingPaymentGrantParams) { const grant = await this.client!.grant.request( { - url: walletAddress.authServer + url: walletAddress.authServer, }, { access_token: { access: [ { type: 'quote', - actions: ['create'] + actions: ['create'], }, { type: 'outgoing-payment', @@ -509,29 +658,31 @@ export class OpenPaymentsService { debitAmount: { value: amount.value, assetScale: walletAddress.assetScale, - assetCode: walletAddress.assetCode + assetCode: walletAddress.assetCode, }, - interval: amount.interval - } - } - ] + interval: amount.interval, + }, + }, + ], }, interact: { start: ['redirect'], finish: { method: 'redirect', uri: OPEN_PAYMENTS_REDIRECT_URL, - nonce: clientNonce - } - } - } - ) + nonce: clientNonce, + }, + }, + }, + ); if (!isPendingGrant(grant)) { - throw new Error('Expected interactive grant. Received non-pending grant.') + throw new Error( + 'Expected interactive grant. Received non-pending grant.', + ); } - return grant + return grant; } private async verifyInteractionHash({ @@ -539,110 +690,129 @@ export class OpenPaymentsService { interactRef, interactNonce, hash, - authServer + authServer, }: VerifyInteractionHashParams): Promise { - const grantEndpoint = new URL(authServer).origin + '/' + const grantEndpoint = new URL(authServer).origin + '/'; const data = new TextEncoder().encode( - `${clientNonce}\n${interactNonce}\n${interactRef}\n${grantEndpoint}` - ) + `${clientNonce}\n${interactNonce}\n${interactRef}\n${grantEndpoint}`, + ); - const digest = await crypto.subtle.digest('SHA-256', data) + const digest = await crypto.subtle.digest('SHA-256', data); const calculatedHash = btoa( - String.fromCharCode.apply(null, new Uint8Array(digest)) - ) - if (calculatedHash !== hash) throw new Error('Invalid interaction hash') + String.fromCharCode.apply(null, new Uint8Array(digest)), + ); + if (calculatedHash !== hash) throw new Error('Invalid interaction hash'); } - private async getInteractionInfo(url: string): Promise { - return await new Promise((res) => { - this.browser.tabs.create({ url }).then((tab) => { - if (!tab.id) return - const getInteractionInfo: TabUpdateCallback = async ( - tabId, - changeInfo - ) => { - if (tabId !== tab.id) return - try { - const tabUrl = new URL(changeInfo.url || '') - const interactRef = tabUrl.searchParams.get('interact_ref') - const hash = tabUrl.searchParams.get('hash') - const result = tabUrl.searchParams.get('result') - - if ( - (interactRef && hash) || - result === 'grant_rejected' || - result === 'grant_invalid' - ) { - this.browser.tabs.onUpdated.removeListener(getInteractionInfo) - } - - if (interactRef && hash) { - res({ interactRef, hash, tabId }) - } - } catch { - /* do nothing */ - } + private async getInteractionInfo( + url: string, + existingTabId?: TabId, + ): Promise { + const { resolve, reject, promise } = withResolvers(); + + const tab = existingTabId + ? await this.browser.tabs.update(existingTabId, { url }) + : await this.browser.tabs.create({ url }); + if (!tab.id) { + reject(new Error('Could not create/update tab')); + return promise; + } + + const tabCloseListener: TabRemovedCallback = (tabId) => { + if (tabId !== tab.id) return; + + this.browser.tabs.onRemoved.removeListener(tabCloseListener); + reject(new ErrorWithKey('connectWallet_error_tabClosed')); + }; + + const getInteractionInfo: TabUpdateCallback = async (tabId, changeInfo) => { + if (tabId !== tab.id) return; + try { + const tabUrl = new URL(changeInfo.url || ''); + const interactRef = tabUrl.searchParams.get('interact_ref'); + const hash = tabUrl.searchParams.get('hash'); + const result = tabUrl.searchParams.get('result'); + + if ( + (interactRef && hash) || + result === 'grant_rejected' || + result === 'grant_invalid' + ) { + this.browser.tabs.onUpdated.removeListener(getInteractionInfo); + this.browser.tabs.onRemoved.removeListener(tabCloseListener); } - this.browser.tabs.onUpdated.addListener(getInteractionInfo) - }) - }) + + if (interactRef && hash) { + resolve({ interactRef, hash, tabId }); + } else if (result === 'grant_rejected') { + reject(new ErrorWithKey('connectWallet_error_grantRejected')); + } + } catch { + /* do nothing */ + } + }; + + this.browser.tabs.onRemoved.addListener(tabCloseListener); + this.browser.tabs.onUpdated.addListener(getInteractionInfo); + + return promise; } async disconnectWallet() { const { recurringGrant, oneTimeGrant } = await this.storage.get([ 'recurringGrant', - 'oneTimeGrant' - ]) + 'oneTimeGrant', + ]); if (!recurringGrant && !oneTimeGrant) { - return + return; } if (recurringGrant) { - await this.cancelGrant(recurringGrant.continue) - this.isGrantUsable.recurring = false + await this.cancelGrant(recurringGrant.continue); + this.isGrantUsable.recurring = false; } if (oneTimeGrant) { - await this.cancelGrant(oneTimeGrant.continue) - this.isGrantUsable.oneTime = false + await this.cancelGrant(oneTimeGrant.continue); + this.isGrantUsable.oneTime = false; } - await this.storage.clear() - this.grant = null + await this.storage.clear(); + this.grant = null; } private async cancelGrant(grantContinuation: GrantDetails['continue']) { try { - await this.client!.grant.cancel(grantContinuation) + await this.client!.grant.cancel(grantContinuation); } catch (error) { - if (isInvalidClientError(error)) { + if (isInvalidClientError(error) || isInvalidContinuationError(error)) { // key already removed from wallet - return + return; } - throw error + throw error; } } async generateKeys() { - if (await this.storage.keyPairExists()) return + if (await this.storage.keyPairExists()) return; - const { privateKey, publicKey } = await generateEd25519KeyPair() - const keyId = crypto.randomUUID() - const jwk = exportJWK(publicKey, keyId) + const { privateKey, publicKey } = await generateEd25519KeyPair(); + const keyId = crypto.randomUUID(); + const jwk = exportJWK(publicKey, keyId); await this.storage.set({ privateKey: bytesToHex(privateKey), publicKey: btoa(JSON.stringify(jwk)), - keyId - }) + keyId, + }); } async createOutgoingPayment({ walletAddress, amount, - incomingPaymentId + incomingPaymentId, }: CreateOutgoingPaymentParams): Promise { const outgoingPayment = (await this.client!.outgoingPayment.create( { accessToken: this.token.value, - url: walletAddress.resourceServer + url: walletAddress.resourceServer, }, { incomingPayment: incomingPaymentId, @@ -650,34 +820,34 @@ export class OpenPaymentsService { debitAmount: { value: amount, assetCode: walletAddress.assetCode, - assetScale: walletAddress.assetScale + assetScale: walletAddress.assetScale, }, metadata: { - source: 'Web Monetization' - } - } - )) as OutgoingPayment + source: 'Web Monetization', + }, + }, + )) as OutgoingPayment; if (outgoingPayment.grantSpentDebitAmount) { this.storage.updateSpentAmount( this.grant!.type, - outgoingPayment.grantSpentDebitAmount.value - ) + outgoingPayment.grantSpentDebitAmount.value, + ); } - await this.storage.setState({ out_of_funds: false }) + await this.storage.setState({ out_of_funds: false }); - return outgoingPayment + return outgoingPayment; } async probeDebitAmount( amount: AmountValue, incomingPayment: IncomingPayment['id'], - sender: WalletAddress + sender: WalletAddress, ): Promise { await this.client!.quote.create( { url: sender.resourceServer, - accessToken: this.token.value + accessToken: this.token.value, }, { method: 'ilp', @@ -686,23 +856,23 @@ export class OpenPaymentsService { debitAmount: { value: amount, assetCode: sender.assetCode, - assetScale: sender.assetScale - } - } - ) + assetScale: sender.assetScale, + }, + }, + ); } async reconnectWallet() { try { - await this.rotateToken() + await this.rotateToken(); } catch (error) { if (isInvalidClientError(error)) { - const msg = this.t('connectWallet_error_invalidClient') - throw new Error(msg, { cause: error }) + const msg = this.t('connectWallet_error_invalidClient'); + throw new Error(msg, { cause: error }); } - throw error + throw error; } - await this.storage.setState({ key_revoked: false }) + await this.storage.setState({ key_revoked: false }); } /** @@ -712,101 +882,106 @@ export class OpenPaymentsService { */ private async _switchGrant(): Promise { if (!this.isAnyGrantUsable()) { - return null + return null; } - this.logger.debug('Switching from grant', this.grant?.type) + this.logger.debug('Switching from grant', this.grant?.type); const { oneTimeGrant, recurringGrant } = await this.storage.get([ 'oneTimeGrant', - 'recurringGrant' - ]) + 'recurringGrant', + ]); if (this.grant?.type === 'recurring') { - this.isGrantUsable.recurring = false + this.isGrantUsable.recurring = false; if (oneTimeGrant) { - this.grant = oneTimeGrant - return 'one-time' + this.grant = oneTimeGrant; + return 'one-time'; } } else if (this.grant?.type === 'one-time') { - this.isGrantUsable.oneTime = false + this.isGrantUsable.oneTime = false; if (recurringGrant) { - this.grant = recurringGrant - return 'recurring' + this.grant = recurringGrant; + return 'recurring'; } } - return null + return null; } async rotateToken() { if (!this.grant) { - throw new Error('No grant to rotate token for') + throw new Error('No grant to rotate token for'); } - const rotate = this.deduplicator.dedupe(this.client!.token.rotate) + const rotate = this.deduplicator.dedupe(this.client!.token.rotate); const newToken = await rotate({ url: this.token.manageUrl, - accessToken: this.token.value - }) + accessToken: this.token.value, + }); const accessToken: AccessToken = { value: newToken.access_token.value, - manageUrl: newToken.access_token.manage - } + manageUrl: newToken.access_token.manage, + }; if (this.grant.type === 'recurring') { - this.storage.set({ recurringGrant: { ...this.grant, accessToken } }) + this.storage.set({ recurringGrant: { ...this.grant, accessToken } }); } else { - this.storage.set({ oneTimeGrant: { ...this.grant, accessToken } }) + this.storage.set({ oneTimeGrant: { ...this.grant, accessToken } }); } - this.grant = { ...this.grant, accessToken } + this.grant = { ...this.grant, accessToken }; } } const isOpenPaymentsClientError = (error: any) => - error instanceof OpenPaymentsClientError + error instanceof OpenPaymentsClientError; export const isKeyRevokedError = (error: any) => { - if (!isOpenPaymentsClientError(error)) return false - return isInvalidClientError(error) || isSignatureValidationError(error) -} + if (!isOpenPaymentsClientError(error)) return false; + return isInvalidClientError(error) || isSignatureValidationError(error); +}; // AUTH SERVER error export const isInvalidClientError = (error: any) => { - if (!isOpenPaymentsClientError(error)) return false - return error.status === 400 && error.code === 'invalid_client' -} + if (!isOpenPaymentsClientError(error)) return false; + return error.status === 400 && error.code === 'invalid_client'; +}; + +export const isInvalidContinuationError = (error: any) => { + if (!isOpenPaymentsClientError(error)) return false; + return error.status === 401 && error.code === 'invalid_continuation'; +}; // RESOURCE SERVER error. Create outgoing payment and create quote can fail // with: `Signature validation error: could not find key in list of client keys` export const isSignatureValidationError = (error: any) => { - if (!isOpenPaymentsClientError(error)) return false + if (!isOpenPaymentsClientError(error)) return false; return ( error.status === 401 && error.description?.includes('Signature validation error') - ) -} + ); +}; export const isTokenExpiredError = (error: any) => { - if (!isOpenPaymentsClientError(error)) return false - return isTokenInvalidError(error) || isTokenInactiveError(error) -} + if (!isOpenPaymentsClientError(error)) return false; + return isTokenInvalidError(error) || isTokenInactiveError(error); +}; export const isTokenInvalidError = (error: OpenPaymentsClientError) => { - return error.status === 401 && error.description === 'Invalid Token' -} + return error.status === 401 && error.description === 'Invalid Token'; +}; export const isTokenInactiveError = (error: OpenPaymentsClientError) => { - return error.status === 403 && error.description === 'Inactive Token' -} + return error.status === 403 && error.description === 'Inactive Token'; +}; // happens during quoting only export const isNonPositiveAmountError = (error: any) => { - if (!isOpenPaymentsClientError(error)) return false + if (!isOpenPaymentsClientError(error)) return false; return ( error.status === 400 && error.description?.toLowerCase()?.includes('non-positive receive amount') - ) -} + ); +}; export const isOutOfBalanceError = (error: any) => { - if (!isOpenPaymentsClientError(error)) return false - return error.status === 403 && error.description === 'unauthorized' -} + if (!isOpenPaymentsClientError(error)) return false; + return error.status === 403 && error.description === 'unauthorized'; +}; export const isInvalidReceiverError = (error: any) => { - if (!isOpenPaymentsClientError(error)) return false - return error.status === 400 && error.description === 'invalid receiver' -} + if (!isOpenPaymentsClientError(error)) return false; + return error.status === 400 && error.description === 'invalid receiver'; +}; diff --git a/src/background/services/paymentSession.ts b/src/background/services/paymentSession.ts index b4ec6fe1..a9207185 100644 --- a/src/background/services/paymentSession.ts +++ b/src/background/services/paymentSession.ts @@ -2,47 +2,51 @@ import { isPendingGrant, type IncomingPayment, type OutgoingPayment, - type WalletAddress -} from '@interledger/open-payments/dist/types' -import { sendMonetizationEvent } from '../lib/messages' -import { bigIntMax, convert } from '@/shared/helpers' -import { transformBalance } from '@/popup/lib/utils' + type WalletAddress, +} from '@interledger/open-payments/dist/types'; +import { bigIntMax, convert } from '@/shared/helpers'; +import { transformBalance } from '@/popup/lib/utils'; import { isInvalidReceiverError, isKeyRevokedError, isNonPositiveAmountError, isOutOfBalanceError, - isTokenExpiredError -} from './openPayments' -import { getNextSendableAmount } from '@/background/utils' -import type { EventsService, OpenPaymentsService, TabState } from '.' -import type { MonetizationEventDetails } from '@/shared/messages' -import type { AmountValue } from '@/shared/types' -import type { Logger } from '@/shared/logger' - -const HOUR_MS = 3600 * 1000 -const MIN_SEND_AMOUNT = 1n // 1 unit -const MAX_INVALID_RECEIVER_ATTEMPTS = 2 - -type PaymentSessionSource = 'tab-change' | 'request-id-reused' | 'new-link' -type IncomingPaymentSource = 'one-time' | 'continuous' + isTokenExpiredError, +} from './openPayments'; +import { getNextSendableAmount } from '@/background/utils'; +import type { EventsService, OpenPaymentsService, TabState } from '.'; +import type { + BackgroundToContentMessage, + MessageManager, + MonetizationEventDetails, + MonetizationEventPayload, +} from '@/shared/messages'; +import type { AmountValue } from '@/shared/types'; +import type { Logger } from '@/shared/logger'; + +const HOUR_MS = 3600 * 1000; +const MIN_SEND_AMOUNT = 1n; // 1 unit +const MAX_INVALID_RECEIVER_ATTEMPTS = 2; + +type PaymentSessionSource = 'tab-change' | 'request-id-reused' | 'new-link'; +type IncomingPaymentSource = 'one-time' | 'continuous'; export class PaymentSession { - private rate: string - private active: boolean = false + private rate: string; + private active: boolean = false; /** Invalid receiver (providers not peered or other reasons) */ - private isInvalid: boolean = false - private countInvalidReceiver: number = 0 - private isDisabled: boolean = false - private incomingPaymentUrl: string - private incomingPaymentExpiresAt: number - private amount: string - private intervalInMs: number - private probingId: number - private shouldRetryImmediately: boolean = false - - private interval: ReturnType | null = null - private timeout: ReturnType | null = null + private isInvalid: boolean = false; + private countInvalidReceiver: number = 0; + private isDisabled: boolean = false; + private incomingPaymentUrl: string; + private incomingPaymentExpiresAt: number; + private amount: string; + private intervalInMs: number; + private probingId: number; + private shouldRetryImmediately: boolean = false; + + private interval: ReturnType | null = null; + private timeout: ReturnType | null = null; constructor( private receiver: WalletAddress, @@ -54,31 +58,32 @@ export class PaymentSession { private events: EventsService, private tabState: TabState, private url: string, - private logger: Logger + private logger: Logger, + private message: MessageManager, ) {} async adjustAmount(rate: AmountValue): Promise { - this.probingId = Date.now() - const localProbingId = this.probingId - this.rate = rate + this.probingId = Date.now(); + const localProbingId = this.probingId; + this.rate = rate; // The amount that needs to be sent every second. // In senders asset scale already. - let amountToSend = BigInt(this.rate) / 3600n - const senderAssetScale = this.sender.assetScale - const receiverAssetScale = this.receiver.assetScale - const isCrossCurrency = this.sender.assetCode !== this.receiver.assetCode + let amountToSend = BigInt(this.rate) / 3600n; + const senderAssetScale = this.sender.assetScale; + const receiverAssetScale = this.receiver.assetScale; + const isCrossCurrency = this.sender.assetCode !== this.receiver.assetCode; if (!isCrossCurrency) { if (amountToSend <= MIN_SEND_AMOUNT) { // We need to add another unit when using a debit amount, since // @interledger/pay subtracts one unit. if (senderAssetScale <= receiverAssetScale) { - amountToSend = MIN_SEND_AMOUNT + 1n + amountToSend = MIN_SEND_AMOUNT + 1n; } else if (senderAssetScale > receiverAssetScale) { // If the sender scale is greater than the receiver scale, the unit // issue will not be present. - amountToSend = MIN_SEND_AMOUNT + amountToSend = MIN_SEND_AMOUNT; } } @@ -89,76 +94,76 @@ export class PaymentSession { const amountInReceiversScale = convert( amountToSend, senderAssetScale, - receiverAssetScale - ) + receiverAssetScale, + ); if (amountInReceiversScale === 0n) { amountToSend = convert( MIN_SEND_AMOUNT, receiverAssetScale, - senderAssetScale - ) + senderAssetScale, + ); } } } // This all will eventually get replaced by OpenPayments response update // that includes a min rate that we can directly use. - await this.setIncomingPaymentUrl() + await this.setIncomingPaymentUrl(); const amountIter = getNextSendableAmount( senderAssetScale, receiverAssetScale, - bigIntMax(amountToSend, MIN_SEND_AMOUNT) - ) + bigIntMax(amountToSend, MIN_SEND_AMOUNT), + ); - amountToSend = BigInt(amountIter.next().value) + amountToSend = BigInt(amountIter.next().value); while (true) { if (this.probingId !== localProbingId) { // In future we can throw `new AbortError()` - throw new DOMException('Aborting existing probing', 'AbortError') + throw new DOMException('Aborting existing probing', 'AbortError'); } try { await this.openPaymentsService.probeDebitAmount( amountToSend.toString(), this.incomingPaymentUrl, - this.sender - ) - this.setAmount(amountToSend) - break + this.sender, + ); + this.setAmount(amountToSend); + break; } catch (e) { if (isTokenExpiredError(e)) { - await this.openPaymentsService.rotateToken() + await this.openPaymentsService.rotateToken(); } else if (isNonPositiveAmountError(e)) { - amountToSend = BigInt(amountIter.next().value) - continue + amountToSend = BigInt(amountIter.next().value); + continue; } else if (isInvalidReceiverError(e)) { - this.markInvalid() + this.markInvalid(); this.events.emit('open_payments.invalid_receiver', { - tabId: this.tabId - }) - break + tabId: this.tabId, + }); + break; } else { - throw e + throw e; } } } } get id() { - return this.requestId + return this.requestId; } get disabled() { - return this.isDisabled + return this.isDisabled; } get invalid() { - return this.isInvalid + return this.isInvalid; } disable() { - this.isDisabled = true - this.stop() + this.isDisabled = true; + this.stop(); } /** @@ -167,70 +172,66 @@ export class PaymentSession { * @deprecated */ enable() { - throw new Error('Method not implemented.') + throw new Error('Method not implemented.'); } private markInvalid() { - this.isInvalid = true - this.stop() + this.isInvalid = true; + this.stop(); } stop() { - this.active = false - this.clearTimers() + this.active = false; + this.clearTimers(); } resume() { - this.start('tab-change') + this.start('tab-change'); } private clearTimers() { if (this.interval) { - this.debug(`Clearing interval=${this.timeout}`) - clearInterval(this.interval) - this.interval = null + this.debug(`Clearing interval=${this.timeout}`); + clearInterval(this.interval); + this.interval = null; } if (this.timeout) { - this.debug(`Clearing timeout=${this.timeout}`) - clearTimeout(this.timeout) - this.timeout = null + this.debug(`Clearing timeout=${this.timeout}`); + clearTimeout(this.timeout); + this.timeout = null; } } private debug(message: string) { this.logger.debug( `[PAYMENT SESSION] requestId=${this.requestId}; receiver=${this.receiver.id}\n\n`, - ` ${message}` - ) + ` ${message}`, + ); } async start(source: PaymentSessionSource) { this.debug( - `Attempting to start; source=${source} active=${this.active} disabled=${this.isDisabled} isInvalid=${this.isInvalid}` - ) - if (this.active || this.isDisabled || this.isInvalid) return - this.debug(`Session started; source=${source}`) - this.active = true + `Attempting to start; source=${source} active=${this.active} disabled=${this.isDisabled} isInvalid=${this.isInvalid}`, + ); + if (this.active || this.isDisabled || this.isInvalid) return; + this.debug(`Session started; source=${source}`); + this.active = true; - await this.setIncomingPaymentUrl() + await this.setIncomingPaymentUrl(); const { waitTime, monetizationEvent } = this.tabState.getOverpayingDetails( this.tabId, this.url, - this.receiver.id - ) + this.receiver.id, + ); - this.debug(`Overpaying: waitTime=${waitTime}`) + this.debug(`Overpaying: waitTime=${waitTime}`); if (monetizationEvent && source !== 'tab-change') { - sendMonetizationEvent({ - tabId: this.tabId, - frameId: this.frameId, - payload: { - requestId: this.requestId, - details: monetizationEvent - } - }) + this.sendMonetizationEvent({ + requestId: this.requestId, + details: monetizationEvent, + }); } // Uncomment this after we perform the Rafiki test and remove the leftover @@ -252,62 +253,71 @@ export class PaymentSession { // Leftover const continuePayment = () => { - if (!this.canContinuePayment) return + if (!this.canContinuePayment) return; // alternatively (leftover) after we perform the Rafiki test, we can just // skip the `.then()` here and call setTimeout recursively immediately void this.payContinuous().then(() => { this.timeout = setTimeout( () => { - continuePayment() + continuePayment(); }, - this.shouldRetryImmediately ? 0 : this.intervalInMs - ) - }) - } + this.shouldRetryImmediately ? 0 : this.intervalInMs, + ); + }); + }; if (this.canContinuePayment) { this.timeout = setTimeout(async () => { - await this.payContinuous() + await this.payContinuous(); this.timeout = setTimeout( () => { - continuePayment() + continuePayment(); }, - this.shouldRetryImmediately ? 0 : this.intervalInMs - ) - }, waitTime) + this.shouldRetryImmediately ? 0 : this.intervalInMs, + ); + }, waitTime); } } + private async sendMonetizationEvent(payload: MonetizationEventPayload) { + await this.message.sendToTab( + this.tabId, + this.frameId, + 'MONETIZATION_EVENT', + payload, + ); + } + private get canContinuePayment() { - return this.active && !this.isDisabled && !this.isInvalid + return this.active && !this.isDisabled && !this.isInvalid; } private async setIncomingPaymentUrl(reset?: boolean) { - if (this.incomingPaymentUrl && !reset) return + if (this.incomingPaymentUrl && !reset) return; try { - const incomingPayment = await this.createIncomingPayment('continuous') - this.incomingPaymentUrl = incomingPayment.id + const incomingPayment = await this.createIncomingPayment('continuous'); + this.incomingPaymentUrl = incomingPayment.id; } catch (error) { if (isKeyRevokedError(error)) { - this.events.emit('open_payments.key_revoked') - return + this.events.emit('open_payments.key_revoked'); + return; } - throw error + throw error; } } private async createIncomingPayment( - source: IncomingPaymentSource + source: IncomingPaymentSource, ): Promise { const expiresAt = new Date( - Date.now() + 1000 * (source === 'continuous' ? 60 * 10 : 30) - ).toISOString() + Date.now() + 1000 * (source === 'continuous' ? 60 * 10 : 30), + ).toISOString(); const incomingPaymentGrant = await this.openPaymentsService.client!.grant.request( { - url: this.receiver.authServer + url: this.receiver.authServer, }, { access_token: { @@ -315,110 +325,108 @@ export class PaymentSession { { type: 'incoming-payment', actions: ['create'], - identifier: this.receiver.id - } - ] - } - } - ) + identifier: this.receiver.id, + }, + ], + }, + }, + ); if (isPendingGrant(incomingPaymentGrant)) { - throw new Error('Expected non-interactive grant. Received pending grant.') + throw new Error( + 'Expected non-interactive grant. Received pending grant.', + ); } const incomingPayment = await this.openPaymentsService.client!.incomingPayment.create( { url: this.receiver.resourceServer, - accessToken: incomingPaymentGrant.access_token.value + accessToken: incomingPaymentGrant.access_token.value, }, { walletAddress: this.receiver.id, expiresAt, metadata: { - source: 'Web Monetization' - } - } - ) + source: 'Web Monetization', + }, + }, + ); if (incomingPayment.expiresAt) { this.incomingPaymentExpiresAt = new Date( - incomingPayment.expiresAt - ).valueOf() + incomingPayment.expiresAt, + ).valueOf(); } // Revoke grant to avoid leaving users with unused, dangling grants. await this.openPaymentsService.client!.grant.cancel({ url: incomingPaymentGrant.continue.uri, - accessToken: incomingPaymentGrant.continue.access_token.value - }) + accessToken: incomingPaymentGrant.continue.access_token.value, + }); - return incomingPayment + return incomingPayment; } async pay(amount: number) { if (this.isDisabled) { - throw new Error('Attempted to send a payment to a disabled session.') + throw new Error('Attempted to send a payment to a disabled session.'); } const incomingPayment = await this.createIncomingPayment('one-time').catch( (error) => { if (isKeyRevokedError(error)) { - this.events.emit('open_payments.key_revoked') - return + this.events.emit('open_payments.key_revoked'); + return; } - throw error - } - ) - if (!incomingPayment) return + throw error; + }, + ); + if (!incomingPayment) return; - let outgoingPayment: OutgoingPayment | undefined + let outgoingPayment: OutgoingPayment | undefined; try { outgoingPayment = await this.openPaymentsService.createOutgoingPayment({ walletAddress: this.sender, incomingPaymentId: incomingPayment.id, - amount: (amount * 10 ** this.sender.assetScale).toFixed(0) - }) + amount: (amount * 10 ** this.sender.assetScale).toFixed(0), + }); } catch (e) { if (isKeyRevokedError(e)) { - this.events.emit('open_payments.key_revoked') + this.events.emit('open_payments.key_revoked'); } else if (isTokenExpiredError(e)) { - await this.openPaymentsService.rotateToken() + await this.openPaymentsService.rotateToken(); } else { - throw e + throw e; } } finally { if (outgoingPayment) { - const { receiveAmount, receiver: incomingPayment } = outgoingPayment - - sendMonetizationEvent({ - tabId: this.tabId, - frameId: this.frameId, - payload: { - requestId: this.requestId, - details: { - amountSent: { - currency: receiveAmount.assetCode, - value: transformBalance( - receiveAmount.value, - receiveAmount.assetScale - ) - }, - incomingPayment, - paymentPointer: this.receiver.id - } - } - }) + const { receiveAmount, receiver: incomingPayment } = outgoingPayment; + + this.sendMonetizationEvent({ + requestId: this.requestId, + details: { + amountSent: { + currency: receiveAmount.assetCode, + value: transformBalance( + receiveAmount.value, + receiveAmount.assetScale, + ), + }, + incomingPayment, + paymentPointer: this.receiver.id, + }, + }); } } - return outgoingPayment?.debitAmount + return outgoingPayment?.debitAmount; } private setAmount(amount: bigint): void { - this.amount = amount.toString() - this.intervalInMs = Number((amount * BigInt(HOUR_MS)) / BigInt(this.rate)) + this.amount = amount.toString(); + this.intervalInMs = Number((amount * BigInt(HOUR_MS)) / BigInt(this.rate)); } private async payContinuous() { @@ -427,69 +435,68 @@ export class PaymentSession { await this.openPaymentsService.createOutgoingPayment({ walletAddress: this.sender, incomingPaymentId: this.incomingPaymentUrl, - amount: this.amount - }) - const { receiveAmount, receiver: incomingPayment } = outgoingPayment + amount: this.amount, + }); + const { receiveAmount, receiver: incomingPayment } = outgoingPayment; const monetizationEventDetails: MonetizationEventDetails = { amountSent: { currency: receiveAmount.assetCode, - value: transformBalance(receiveAmount.value, receiveAmount.assetScale) + value: transformBalance( + receiveAmount.value, + receiveAmount.assetScale, + ), }, incomingPayment, - paymentPointer: this.receiver.id - } + paymentPointer: this.receiver.id, + }; - sendMonetizationEvent({ - tabId: this.tabId, - frameId: this.frameId, - payload: { - requestId: this.requestId, - details: monetizationEventDetails - } - }) + this.sendMonetizationEvent({ + requestId: this.requestId, + details: monetizationEventDetails, + }); // TO DO: find a better source of truth for deciding if overpaying is applicable if (this.intervalInMs > 1000) { this.tabState.saveOverpaying(this.tabId, this.url, { walletAddressId: this.receiver.id, monetizationEvent: monetizationEventDetails, - intervalInMs: this.intervalInMs - }) + intervalInMs: this.intervalInMs, + }); } - this.shouldRetryImmediately = false + this.shouldRetryImmediately = false; } catch (e) { if (isKeyRevokedError(e)) { - this.events.emit('open_payments.key_revoked') + this.events.emit('open_payments.key_revoked'); } else if (isTokenExpiredError(e)) { - await this.openPaymentsService.rotateToken() - this.shouldRetryImmediately = true + await this.openPaymentsService.rotateToken(); + this.shouldRetryImmediately = true; } else if (isOutOfBalanceError(e)) { - const switched = await this.openPaymentsService.switchGrant() + const switched = await this.openPaymentsService.switchGrant(); if (switched === null) { - this.events.emit('open_payments.out_of_funds') + this.events.emit('open_payments.out_of_funds'); } else { - this.shouldRetryImmediately = true + this.shouldRetryImmediately = true; } } else if (isInvalidReceiverError(e)) { if (Date.now() >= this.incomingPaymentExpiresAt) { - await this.setIncomingPaymentUrl(true) - this.shouldRetryImmediately = true + await this.setIncomingPaymentUrl(true); + this.shouldRetryImmediately = true; } else { - ++this.countInvalidReceiver + ++this.countInvalidReceiver; if ( this.countInvalidReceiver >= MAX_INVALID_RECEIVER_ATTEMPTS && !this.isInvalid ) { - this.markInvalid() + this.markInvalid(); this.events.emit('open_payments.invalid_receiver', { - tabId: this.tabId - }) + tabId: this.tabId, + }); } else { - this.shouldRetryImmediately = true + this.shouldRetryImmediately = true; } } } else { - throw e + throw e; } } } diff --git a/src/background/services/sendToPopup.ts b/src/background/services/sendToPopup.ts index aef24db5..22a702d9 100644 --- a/src/background/services/sendToPopup.ts +++ b/src/background/services/sendToPopup.ts @@ -1,53 +1,53 @@ -import type { Runtime } from 'webextension-polyfill' +import type { Runtime } from 'webextension-polyfill'; import { BACKGROUND_TO_POPUP_CONNECTION_NAME as CONNECTION_NAME, type BackgroundToPopupMessage, - type BackgroundToPopupMessagesMap -} from '@/shared/messages' -import type { Cradle } from '@/background/container' + type BackgroundToPopupMessagesMap, +} from '@/shared/messages'; +import type { Cradle } from '@/background/container'; export class SendToPopup { - private browser: Cradle['browser'] + private browser: Cradle['browser']; - private isConnected = false - private port: Runtime.Port - private queue = new Map() + private isConnected = false; + private port: Runtime.Port; + private queue = new Map(); constructor({ browser }: Cradle) { - Object.assign(this, { browser }) + Object.assign(this, { browser }); } start() { this.browser.runtime.onConnect.addListener((port) => { - if (port.name !== CONNECTION_NAME) return + if (port.name !== CONNECTION_NAME) return; if (port.error) { - return + return; } - this.port = port - this.isConnected = true + this.port = port; + this.isConnected = true; for (const [type, data] of this.queue) { - this.send(type, data) - this.queue.delete(type) + this.send(type, data); + this.queue.delete(type); } port.onDisconnect.addListener(() => { - this.isConnected = false - }) - }) + this.isConnected = false; + }); + }); } get isPopupOpen() { - return this.isConnected + return this.isConnected; } async send( type: T, - data: BackgroundToPopupMessagesMap[T] + data: BackgroundToPopupMessagesMap[T], ) { if (!this.isConnected) { - this.queue.set(type, data) - return + this.queue.set(type, data); + return; } - const message = { type, data } as BackgroundToPopupMessage - this.port.postMessage(message) + const message = { type, data } as BackgroundToPopupMessage; + this.port.postMessage(message); } } diff --git a/src/background/services/storage.ts b/src/background/services/storage.ts index 7d9ace01..d592adfa 100644 --- a/src/background/services/storage.ts +++ b/src/background/services/storage.ts @@ -2,13 +2,14 @@ import type { AmountValue, ExtensionState, GrantDetails, + PopupTransientState, Storage, StorageKey, - WalletAmount -} from '@/shared/types' -import { bigIntMax, objectEquals, ThrottleBatch } from '@/shared/helpers' -import { computeBalance } from '../utils' -import type { Cradle } from '../container' + WalletAmount, +} from '@/shared/types'; +import { bigIntMax, objectEquals, ThrottleBatch } from '@/shared/helpers'; +import { computeBalance } from '../utils'; +import type { Cradle } from '../container'; const defaultStorage = { /** @@ -30,59 +31,61 @@ const defaultStorage = { oneTimeGrantSpentAmount: '0', rateOfPay: null, minRateOfPay: null, - maxRateOfPay: null -} satisfies Omit + maxRateOfPay: null, +} satisfies Omit; export class StorageService { - private browser: Cradle['browser'] - private events: Cradle['events'] + private browser: Cradle['browser']; + private events: Cradle['events']; - private setSpentAmountRecurring: ThrottleBatch<[amount: string]> - private setSpentAmountOneTime: ThrottleBatch<[amount: string]> + private setSpentAmountRecurring: ThrottleBatch<[amount: string]>; + private setSpentAmountOneTime: ThrottleBatch<[amount: string]>; // used as an optimization/cache - private currentState: Storage['state'] | null = null + private currentState: Storage['state'] | null = null; + + private popupTransientState: PopupTransientState = {}; constructor({ browser, events }: Cradle) { - Object.assign(this, { browser, events }) + Object.assign(this, { browser, events }); this.setSpentAmountRecurring = new ThrottleBatch( (amount) => this.setSpentAmount('recurring', amount), (args) => [args.reduce((max, [v]) => bigIntMax(max, v), '0')], - 1000 - ) + 1000, + ); this.setSpentAmountOneTime = new ThrottleBatch( (amount) => this.setSpentAmount('one-time', amount), (args) => [args.reduce((max, [v]) => bigIntMax(max, v), '0')], - 1000 - ) + 1000, + ); } async get( - keys?: TKey[] + keys?: TKey[], ): Promise<{ [Key in TKey[][number]]: Storage[Key] }> { - const data = await this.browser.storage.local.get(keys) - return data as { [Key in TKey[][number]]: Storage[Key] } + const data = await this.browser.storage.local.get(keys); + return data as { [Key in TKey[][number]]: Storage[Key] }; } async set(data: { - [K in TKey]: Storage[TKey] + [K in TKey]: Storage[TKey]; }): Promise { - await this.browser.storage.local.set(data) + await this.browser.storage.local.set(data); } async clear(): Promise { - await this.set(defaultStorage) - this.currentState = { ...defaultStorage.state } + await this.set(defaultStorage); + this.currentState = { ...defaultStorage.state }; } /** * Needs to run before any other storage `set` call. */ async populate(): Promise { - const data = await this.get(Object.keys(defaultStorage) as StorageKey[]) + const data = await this.get(Object.keys(defaultStorage) as StorageKey[]); if (Object.keys(data).length === 0) { - await this.set(defaultStorage) + await this.set(defaultStorage); } } @@ -90,36 +93,36 @@ export class StorageService { * Migrate storage to given target version. */ async migrate(targetVersion: Storage['version'] = defaultStorage.version) { - const storage = this.browser.storage.local + const storage = this.browser.storage.local; - let { version = 1 } = await this.get(['version']) + let { version = 1 } = await this.get(['version']); if (version === targetVersion) { - return null + return null; } - let data = await storage.get() + let data = await storage.get(); while (version < targetVersion) { - ++version - const migrate = MIGRATIONS[version] + ++version; + const migrate = MIGRATIONS[version]; if (!migrate) { - throw new Error(`No migration available to reach version "${version}"`) + throw new Error(`No migration available to reach version "${version}"`); } - const [newData, deleteKeys = []] = migrate(data) - data = { ...newData, version } - await storage.set(data) - await storage.remove(deleteKeys) + const [newData, deleteKeys = []] = migrate(data); + data = { ...newData, version }; + await storage.set(data); + await storage.remove(deleteKeys); } - return data as unknown as Storage + return data as unknown as Storage; } async getWMState(): Promise { - const { enabled } = await this.get(['enabled']) + const { enabled } = await this.get(['enabled']); - return enabled + return enabled; } async keyPairExists(): Promise { - const keys = await this.get(['privateKey', 'publicKey', 'keyId']) + const keys = await this.get(['privateKey', 'publicKey', 'keyId']); if ( keys.privateKey && typeof keys.privateKey === 'string' && @@ -128,48 +131,48 @@ export class StorageService { keys.keyId && typeof keys.keyId === 'string' ) { - return true + return true; } - return false + return false; } async setState(state: Storage['state']): Promise { - const prevState = this.currentState ?? (await this.get(['state'])).state + const prevState = this.currentState ?? (await this.get(['state'])).state; - const newState: Storage['state'] = { ...this.currentState } + const newState: Storage['state'] = { ...this.currentState }; for (const key of Object.keys(state) as ExtensionState[]) { - newState[key] = state[key] + newState[key] = state[key]; } - this.currentState = newState + this.currentState = newState; if (prevState && objectEquals(prevState, newState)) { - return false + return false; } - await this.set({ state: newState }) + await this.set({ state: newState }); this.events.emit('storage.state_update', { state: newState, - prevState: prevState - }) - return true + prevState: prevState, + }); + return true; } updateSpentAmount(grant: GrantDetails['type'], amount: string) { if (grant === 'recurring') { - this.setSpentAmountRecurring.enqueue(amount) + this.setSpentAmountRecurring.enqueue(amount); } else if (grant === 'one-time') { - this.setSpentAmountOneTime.enqueue(amount) + this.setSpentAmountOneTime.enqueue(amount); } } private async setSpentAmount(grant: GrantDetails['type'], amount: string) { if (grant === 'recurring') { - await this.set({ recurringGrantSpentAmount: amount }) + await this.set({ recurringGrantSpentAmount: amount }); } else if (grant === 'one-time') { - await this.set({ oneTimeGrantSpentAmount: amount }) + await this.set({ oneTimeGrantSpentAmount: amount }); } - const balance = await this.getBalance() - this.events.emit('storage.balance_update', balance) + const balance = await this.getBalance(); + this.events.emit('storage.balance_update', balance); } async getBalance(): Promise< @@ -179,27 +182,42 @@ export class StorageService { 'recurringGrant', 'recurringGrantSpentAmount', 'oneTimeGrant', - 'oneTimeGrantSpentAmount' - ]) + 'oneTimeGrantSpentAmount', + ]); const balanceRecurring = computeBalance( data.recurringGrant, - data.recurringGrantSpentAmount - ) + data.recurringGrantSpentAmount, + ); const balanceOneTime = computeBalance( data.oneTimeGrant, - data.oneTimeGrantSpentAmount - ) - const balance = balanceRecurring + balanceOneTime + data.oneTimeGrantSpentAmount, + ); + const balance = balanceRecurring + balanceOneTime; return { total: balance.toString(), recurring: balanceRecurring.toString(), - oneTime: balanceOneTime.toString() - } + oneTime: balanceOneTime.toString(), + }; } async updateRate(rate: string): Promise { - await this.set({ rateOfPay: rate }) - this.events.emit('storage.rate_of_pay_update', { rate }) + await this.set({ rateOfPay: rate }); + this.events.emit('storage.rate_of_pay_update', { rate }); + } + + setPopupTransientState( + id: T, + update: (prev?: PopupTransientState[T]) => PopupTransientState[T], + ) { + const newState = update(this.popupTransientState[id]); + this.popupTransientState[id] = newState; + + const state = this.getPopupTransientState(); + this.events.emit('storage.popup_transient_state_update', state); + } + + getPopupTransientState(): PopupTransientState { + return this.popupTransientState; } } @@ -207,8 +225,8 @@ export class StorageService { * @param existingData Existing data from previous version. */ type Migration = ( - existingData: Record -) => [data: Record, deleteKeys?: string[]] + existingData: Record, +) => [data: Record, deleteKeys?: string[]]; // There was never a migration to reach 1. // @@ -216,16 +234,16 @@ type Migration = ( // require user to reinstall and setup extension from scratch. const MIGRATIONS: Record = { 2: (data) => { - const deleteKeys = ['amount', 'token', 'grant', 'hasHostPermissions'] + const deleteKeys = ['amount', 'token', 'grant', 'hasHostPermissions']; - data.recurringGrant = null - data.recurringGrantSpentAmount = '0' - data.oneTimeGrant = null - data.oneTimeGrantSpentAmount = '0' - data.state = null + data.recurringGrant = null; + data.recurringGrantSpentAmount = '0'; + data.oneTimeGrant = null; + data.oneTimeGrantSpentAmount = '0'; + data.state = null; if (data.amount?.value && data.token && data.grant) { - const type = data.amount.interval ? 'recurring' : 'one-time' + const type = data.amount.interval ? 'recurring' : 'one-time'; const grantDetails: GrantDetails = { type, @@ -233,37 +251,37 @@ const MIGRATIONS: Record = { value: data.amount.value as string, ...(type === 'recurring' ? { interval: data.amount.interval as string } - : {}) + : {}), } as Required, accessToken: { value: data.token.value as string, - manageUrl: data.token.manage as string + manageUrl: data.token.manage as string, }, continue: { url: data.grant.continueUri as string, - accessToken: data.grant.accessToken as string - } - } + accessToken: data.grant.accessToken as string, + }, + }; if (type === 'recurring') { - data.recurringGrant = grantDetails + data.recurringGrant = grantDetails; } else { - data.oneTimeGrant = grantDetails + data.oneTimeGrant = grantDetails; } } if (data.hasHostPermissions === false) { - data.state = 'missing_host_permissions' + data.state = 'missing_host_permissions'; } - return [data, deleteKeys] + return [data, deleteKeys]; }, 3: (data) => { const newState = data.state && typeof data.state === 'string' ? { [data.state as ExtensionState]: true } - : {} - data.state = newState satisfies Storage['state'] - return [data] - } -} + : {}; + data.state = newState satisfies Storage['state']; + return [data]; + }, +}; diff --git a/src/background/services/tabEvents.ts b/src/background/services/tabEvents.ts index 059a988c..e87b5ce9 100644 --- a/src/background/services/tabEvents.ts +++ b/src/background/services/tabEvents.ts @@ -1,71 +1,82 @@ -import browser from 'webextension-polyfill' -import type { Browser } from 'webextension-polyfill' -import { isOkState, removeQueryParams } from '@/shared/helpers' -import type { Storage, TabId } from '@/shared/types' -import type { Cradle } from '@/background/container' +import { isOkState, removeQueryParams } from '@/shared/helpers'; +import type { PopupTabInfo, Storage, TabId } from '@/shared/types'; +import type { Browser, Tabs } from 'webextension-polyfill'; +import type { Cradle } from '@/background/container'; -const runtime = browser.runtime +type IconPath = Record; const ICONS = { default: { - 32: runtime.getURL('assets/icons/32x32/default.png'), - 48: runtime.getURL('assets/icons/48x48/default.png'), - 128: runtime.getURL('assets/icons/128x128/default.png') + 32: '/assets/icons/32x32/default.png', + 48: '/assets/icons/48x48/default.png', + 128: '/assets/icons/128x128/default.png', }, default_gray: { - 32: runtime.getURL('assets/icons/32x32/default-gray.png'), - 48: runtime.getURL('assets/icons/48x48/default-gray.png'), - 128: runtime.getURL('assets/icons/128x128/default-gray.png') + 32: '/assets/icons/32x32/default-gray.png', + 48: '/assets/icons/48x48/default-gray.png', + 128: '/assets/icons/128x128/default-gray.png', }, enabled_hasLinks: { - 32: runtime.getURL('assets/icons/32x32/enabled-has-links.png'), - 48: runtime.getURL('assets/icons/48x48/enabled-has-links.png'), - 128: runtime.getURL('assets/icons/128x128/enabled-has-links.png') + 32: '/assets/icons/32x32/enabled-has-links.png', + 48: '/assets/icons/48x48/enabled-has-links.png', + 128: '/assets/icons/128x128/enabled-has-links.png', }, enabled_noLinks: { - 32: runtime.getURL('assets/icons/32x32/enabled-no-links.png'), - 48: runtime.getURL('assets/icons/48x48/enabled-no-links.png'), - 128: runtime.getURL('assets/icons/128x128/enabled-no-links.png') + 32: '/assets/icons/32x32/enabled-no-links.png', + 48: '/assets/icons/48x48/enabled-no-links.png', + 128: '/assets/icons/128x128/enabled-no-links.png', }, enabled_warn: { - 32: runtime.getURL('assets/icons/32x32/enabled-warn.png'), - 48: runtime.getURL('assets/icons/48x48/enabled-warn.png'), - 128: runtime.getURL('assets/icons/128x128/enabled-warn.png') + 32: '/assets/icons/32x32/enabled-warn.png', + 48: '/assets/icons/48x48/enabled-warn.png', + 128: '/assets/icons/128x128/enabled-warn.png', }, disabled_hasLinks: { - 32: runtime.getURL('assets/icons/32x32/disabled-has-links.png'), - 48: runtime.getURL('assets/icons/48x48/disabled-has-links.png'), - 128: runtime.getURL('assets/icons/128x128/disabled-has-links.png') + 32: '/assets/icons/32x32/disabled-has-links.png', + 48: '/assets/icons/48x48/disabled-has-links.png', + 128: '/assets/icons/128x128/disabled-has-links.png', }, disabled_noLinks: { - 32: runtime.getURL('assets/icons/32x32/disabled-no-links.png'), - 48: runtime.getURL('assets/icons/48x48/disabled-no-links.png'), - 128: runtime.getURL('assets/icons/128x128/disabled-no-links.png') + 32: '/assets/icons/32x32/disabled-no-links.png', + 48: '/assets/icons/48x48/disabled-no-links.png', + 128: '/assets/icons/128x128/disabled-no-links.png', }, disabled_warn: { - 32: runtime.getURL('assets/icons/32x32/disabled-warn.png'), - 48: runtime.getURL('assets/icons/48x48/disabled-warn.png'), - 128: runtime.getURL('assets/icons/128x128/disabled-warn.png') - } -} + 32: '/assets/icons/32x32/disabled-warn.png', + 48: '/assets/icons/48x48/disabled-warn.png', + 128: '/assets/icons/128x128/disabled-warn.png', + }, +} satisfies Record; type CallbackTab> = - Parameters[0] + Parameters[0]; export class TabEvents { - private storage: Cradle['storage'] - private tabState: Cradle['tabState'] - private sendToPopup: Cradle['sendToPopup'] - private t: Cradle['t'] - private browser: Cradle['browser'] + 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 - }) + browser, + browserName, + }); } onUpdatedTab: CallbackTab<'onUpdated'> = (tabId, changeInfo, tab) => { @@ -74,109 +85,129 @@ export class TabEvents { * if loading and url -> we need to check if state keys include this url. */ if (changeInfo.status === 'loading') { - const url = tab.url ? removeQueryParams(tab.url) : '' - const clearOverpaying = this.tabState.shouldClearOverpaying(tabId, url) + const url = tab.url ? removeQueryParams(tab.url) : ''; + const clearOverpaying = this.tabState.shouldClearOverpaying(tabId, url); - this.tabState.clearSessionsByTabId(tabId) + this.tabState.clearSessionsByTabId(tabId); if (clearOverpaying) { - this.tabState.clearOverpayingByTabId(tabId) + this.tabState.clearOverpayingByTabId(tabId); } - void this.updateVisualIndicators(tabId) + if (!tab.id) return; + void this.updateVisualIndicators(tab); } - } + }; - onRemovedTab: CallbackTab<'onRemoved'> = (tabId, _removeInfo) => { - this.tabState.clearSessionsByTabId(tabId) - this.tabState.clearOverpayingByTabId(tabId) - } + onRemovedTab: CallbackTab<'onRemoved'> = (tabId, info) => { + this.windowState.removeTab(tabId, info.windowId); + this.tabState.clearSessionsByTabId(tabId); + this.tabState.clearOverpayingByTabId(tabId); + }; onActivatedTab: CallbackTab<'onActivated'> = async (info) => { - await this.updateVisualIndicators(info.tabId) - } + 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(tab); + }; onCreatedTab: CallbackTab<'onCreated'> = async (tab) => { - if (!tab.id) return - await this.updateVisualIndicators(tab.id) - } + if (!tab.id) return; + this.windowState.addTab(tab.id, tab.windowId); + await this.updateVisualIndicators(tab); + }; - updateVisualIndicators = async ( - tabId: TabId, - isTabMonetized: boolean = tabId - ? this.tabState.isTabMonetized(tabId) - : false, - hasTabAllSessionsInvalid: boolean = tabId - ? this.tabState.tabHasAllSessionsInvalid(tabId) - : false - ) => { + 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({ + 'state', + ]); + const { path, title } = this.getIconAndTooltip({ enabled, connected, state, - isTabMonetized, - hasTabAllSessionsInvalid - }) + tabInfo, + }); + await this.setIconAndTooltip(tabInfo.tabId, path, title); + }; - this.sendToPopup.send('SET_IS_MONETIZED', isMonetized) - this.sendToPopup.send('SET_ALL_SESSIONS_INVALID', hasTabAllSessionsInvalid) - await this.setIconAndTooltip(path, title, tabId) - } - - // TODO: memoize this call private setIconAndTooltip = async ( - path: (typeof ICONS)[keyof typeof ICONS], + tabId: TabId, + icon: IconPath, title: string, - tabId?: TabId ) => { - await this.browser.action.setIcon({ path, tabId }) - await this.browser.action.setTitle({ title, 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, - isTabMonetized, - hasTabAllSessionsInvalid + tabInfo, }: { - enabled: Storage['enabled'] - connected: Storage['connected'] - state: Storage['state'] - isTabMonetized: boolean - hasTabAllSessionsInvalid: boolean + enabled: Storage['enabled']; + connected: Storage['connected']; + state: Storage['state']; + tabInfo: PopupTabInfo; }) { - let title = this.t('appName') - let iconData = ICONS.default + let title = this.t('appName'); + let iconData = ICONS.default; if (!connected) { // use defaults - } else if (!isOkState(state) || hasTabAllSessionsInvalid) { - iconData = enabled ? ICONS.enabled_warn : ICONS.disabled_warn - const tabStateText = this.t('icon_state_actionRequired') - title = `${title} - ${tabStateText}` + } 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 - : ICONS.enabled_noLinks + : ICONS.enabled_noLinks; } else { iconData = isTabMonetized ? ICONS.disabled_hasLinks - : ICONS.disabled_noLinks + : ICONS.disabled_noLinks; } const tabStateText = isTabMonetized ? this.t('icon_state_monetizationActive') - : this.t('icon_state_monetizationInactive') - title = `${title} - ${tabStateText}` + : this.t('icon_state_monetizationInactive'); + 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 ef40ef5f..27831e9d 100644 --- a/src/background/services/tabState.ts +++ b/src/background/services/tabState.ts @@ -1,140 +1,195 @@ -import type { MonetizationEventDetails } from '@/shared/messages' -import type { TabId } from '@/shared/types' -import type { PaymentSession } from './paymentSession' -import type { Cradle } from '@/background/container' +import type { Tabs } from 'webextension-polyfill'; +import type { MonetizationEventDetails } from '@/shared/messages'; +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 - lastPaymentTimestamp: number - expiresAtTimestamp: number -} + monetizationEvent: MonetizationEventDetails; + lastPaymentTimestamp: number; + expiresAtTimestamp: number; +}; interface SaveOverpayingDetails { - walletAddressId: string - monetizationEvent: MonetizationEventDetails - intervalInMs: number + walletAddressId: string; + monetizationEvent: MonetizationEventDetails; + intervalInMs: number; } -type SessionId = string +type SessionId = string; export class TabState { - private logger: Cradle['logger'] + private logger: Cradle['logger']; - private state = new Map>() - private sessions = new Map>() + private state = new Map>(); + private sessions = new Map>(); + private currentIcon = new Map>(); constructor({ logger }: Cradle) { Object.assign(this, { - logger - }) + logger, + }); } private getOverpayingStateKey(url: string, walletAddressId: string): string { - return `${url}:${walletAddressId}` + return `${url}:${walletAddressId}`; } shouldClearOverpaying(tabId: TabId, url: string): boolean { - const tabState = this.state.get(tabId) - if (!tabState?.size || !url) return false - return ![...tabState.keys()].some((key) => key.startsWith(`${url}:`)) + const tabState = this.state.get(tabId); + if (!tabState?.size || !url) return false; + return ![...tabState.keys()].some((key) => key.startsWith(`${url}:`)); } getOverpayingDetails( tabId: TabId, url: string, - walletAddressId: string + walletAddressId: string, ): { waitTime: number; monetizationEvent?: MonetizationEventDetails } { - const key = this.getOverpayingStateKey(url, walletAddressId) - const state = this.state.get(tabId)?.get(key) - const now = Date.now() + const key = this.getOverpayingStateKey(url, walletAddressId); + const state = this.state.get(tabId)?.get(key); + const now = Date.now(); if (state && state.expiresAtTimestamp > now) { return { waitTime: state.expiresAtTimestamp - now, - monetizationEvent: state.monetizationEvent - } + monetizationEvent: state.monetizationEvent, + }; } return { - waitTime: 0 - } + waitTime: 0, + }; } saveOverpaying( tabId: TabId, url: string, - details: SaveOverpayingDetails + details: SaveOverpayingDetails, ): void { - const { intervalInMs, walletAddressId, monetizationEvent } = details - if (!intervalInMs) return + const { intervalInMs, walletAddressId, monetizationEvent } = details; + if (!intervalInMs) return; - const now = Date.now() - const expiresAtTimestamp = now + intervalInMs + const now = Date.now(); + const expiresAtTimestamp = now + intervalInMs; - const key = this.getOverpayingStateKey(url, walletAddressId) - const state = this.state.get(tabId)?.get(key) + const key = this.getOverpayingStateKey(url, walletAddressId); + const state = this.state.get(tabId)?.get(key); if (!state) { - const tabState = this.state.get(tabId) || new Map() + const tabState = this.state.get(tabId) || new Map(); tabState.set(key, { monetizationEvent, expiresAtTimestamp: expiresAtTimestamp, - lastPaymentTimestamp: now - }) - this.state.set(tabId, tabState) + lastPaymentTimestamp: now, + }); + this.state.set(tabId, tabState); } else { - state.expiresAtTimestamp = expiresAtTimestamp - state.lastPaymentTimestamp = now + state.expiresAtTimestamp = expiresAtTimestamp; + state.lastPaymentTimestamp = now; } } getSessions(tabId: TabId) { - let sessions = this.sessions.get(tabId) + let sessions = this.sessions.get(tabId); if (!sessions) { - sessions = new Map() - this.sessions.set(tabId, sessions) + sessions = new Map(); + this.sessions.set(tabId, sessions); } - return sessions + return sessions; } getEnabledSessions(tabId: TabId) { - return [...this.getSessions(tabId).values()].filter((s) => !s.disabled) + return [...this.getSessions(tabId).values()].filter((s) => !s.disabled); } getPayableSessions(tabId: TabId) { - return this.getEnabledSessions(tabId).filter((s) => !s.invalid) + return this.getEnabledSessions(tabId).filter((s) => !s.invalid); } isTabMonetized(tabId: TabId) { - return this.getEnabledSessions(tabId).length > 0 + return this.getEnabledSessions(tabId).length > 0; } tabHasAllSessionsInvalid(tabId: TabId) { - const sessions = this.getEnabledSessions(tabId) - return sessions.length > 0 && sessions.every((s) => s.invalid) + const sessions = this.getEnabledSessions(tabId); + return sessions.length > 0 && sessions.every((s) => s.invalid); } getAllSessions() { - return [...this.sessions.values()].flatMap((s) => [...s.values()]) + 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); + } + + setIcon(tabId: TabId, icon: Record) { + this.currentIcon.set(tabId, icon); } getAllTabs(): TabId[] { - return [...this.sessions.keys()] + return [...this.sessions.keys()]; } clearOverpayingByTabId(tabId: TabId) { - this.state.delete(tabId) - this.logger.debug(`Cleared overpaying state for tab ${tabId}.`) + this.state.delete(tabId); + this.logger.debug(`Cleared overpaying state for tab ${tabId}.`); } clearSessionsByTabId(tabId: TabId) { - const sessions = this.getSessions(tabId) - if (!sessions.size) return + this.currentIcon.delete(tabId); + + const sessions = this.getSessions(tabId); + if (!sessions.size) return; for (const session of sessions.values()) { - session.stop() + session.stop(); } - this.logger.debug(`Cleared ${sessions.size} sessions for tab ${tabId}.`) - this.sessions.delete(tabId) + this.logger.debug(`Cleared ${sessions.size} sessions for tab ${tabId}.`); + this.sessions.delete(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.test.ts b/src/background/utils.test.ts index ffaba9c7..3ddd6ca1 100644 --- a/src/background/utils.test.ts +++ b/src/background/utils.test.ts @@ -1,19 +1,19 @@ -import { getNextSendableAmount } from './utils' +import { getNextSendableAmount } from './utils'; // same as BuiltinIterator.take(n) function take(iter: IterableIterator, n: number) { - const result: T[] = [] + const result: T[] = []; for (let i = 0; i < n; i++) { - const item = iter.next() - if (item.done) break - result.push(item.value) + const item = iter.next(); + if (item.done) break; + result.push(item.value); } - return result + return result; } describe('getNextSendableAmount', () => { it('from assetScale 8 to 9', () => { - const min = 990_00_000n / 3600n // 0.99XPR per hour == 0.000275 XRP per second (27500 at scale 8) + const min = 990_00_000n / 3600n; // 0.99XPR per hour == 0.000275 XRP per second (27500 at scale 8) expect(take(getNextSendableAmount(8, 9, min), 8)).toEqual([ '27500', '27501', @@ -22,12 +22,12 @@ describe('getNextSendableAmount', () => { '27508', '27515', '27527', - '27547' - ]) - }) + '27547', + ]); + }); it('from assetScale 8 to 2', () => { - const min = 990_00_000n / 3600n + const min = 990_00_000n / 3600n; expect(take(getNextSendableAmount(8, 2, min), 8)).toEqual([ '27500', '1027500', @@ -36,9 +36,9 @@ describe('getNextSendableAmount', () => { '8027500', '15027500', '27027500', - '47027500' - ]) - }) + '47027500', + ]); + }); it('from assetScale 3 to 2', () => { expect(take(getNextSendableAmount(3, 2), 8)).toEqual([ @@ -49,9 +49,9 @@ describe('getNextSendableAmount', () => { '150', '270', '470', - '800' - ]) - }) + '800', + ]); + }); it('from assetScale 2 to 3', () => { expect(take(getNextSendableAmount(2, 3), 8)).toEqual([ @@ -62,9 +62,9 @@ describe('getNextSendableAmount', () => { '15', '27', '47', - '80' - ]) - }) + '80', + ]); + }); it('from assetScale 2 to 2', () => { expect(take(getNextSendableAmount(2, 2), 8)).toEqual([ @@ -75,7 +75,7 @@ describe('getNextSendableAmount', () => { '15', '27', '47', - '80' - ]) - }) -}) + '80', + ]); + }); +}); diff --git a/src/background/utils.ts b/src/background/utils.ts index 1a378abf..0e08fc05 100644 --- a/src/background/utils.ts +++ b/src/background/utils.ts @@ -1,107 +1,121 @@ -import type { AmountValue, GrantDetails, WalletAmount } from '@/shared/types' -import type { Browser, Runtime, Tabs } from 'webextension-polyfill' -import { DEFAULT_SCALE, EXCHANGE_RATES_URL } from './config' -import { notNullOrUndef } from '@/shared/helpers' +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) => { - const window = await browser.windows.getLastFocused() + const window = await browser.windows.getLastFocused(); const activeTabs = await browser.tabs.query({ active: true, - windowId: window.id - }) - return activeTabs[0] -} + windowId: window.id, + }); + return activeTabs[0]; +}; interface ToAmountParams { - value: string - recurring: boolean - assetScale: number + value: string; + recurring: boolean; + assetScale: number; } export const toAmount = ({ value, recurring, - assetScale + assetScale, }: ToAmountParams): WalletAmount => { - const interval = `R/${new Date().toISOString()}/P1M` + const interval = `R/${new Date().toISOString()}/P1M`; return { value: Math.floor(parseFloat(value) * 10 ** assetScale).toString(), - ...(recurring ? { interval } : {}) - } -} + ...(recurring ? { interval } : {}), + }; +}; export const OPEN_PAYMENTS_ERRORS: Record = { 'invalid client': - 'Please make sure that you uploaded the public key for your desired wallet address.' -} + 'Please make sure that you uploaded the public key for your desired wallet address.', +}; export interface GetRateOfPayParams { - rate: string - exchangeRate: number - assetScale: number + rate: string; + exchangeRate: number; + assetScale: number; } export const getRateOfPay = ({ rate, exchangeRate, - assetScale + assetScale, }: GetRateOfPayParams) => { - const scaleDiff = assetScale - DEFAULT_SCALE + const scaleDiff = assetScale - DEFAULT_SCALE; if (exchangeRate < 0.8 || exchangeRate > 1.5) { - const scaledExchangeRate = (1 / exchangeRate) * 10 ** scaleDiff - return BigInt(Math.round(Number(rate) * scaledExchangeRate)).toString() + const scaledExchangeRate = (1 / exchangeRate) * 10 ** scaleDiff; + return BigInt(Math.round(Number(rate) * scaledExchangeRate)).toString(); } - return (Number(rate) * 10 ** scaleDiff).toString() -} + return (Number(rate) * 10 ** scaleDiff).toString(); +}; interface ExchangeRates { - base: string - rates: Record + base: string; + rates: Record; } export const getExchangeRates = async (): Promise => { - const response = await fetch(EXCHANGE_RATES_URL) + const response = await fetch(EXCHANGE_RATES_URL); if (!response.ok) { throw new Error( - `Could not fetch exchange rates. [Status code: ${response.status}]` - ) + `Could not fetch exchange rates. [Status code: ${response.status}]`, + ); } - const rates = await response.json() + const rates = await response.json(); if (!rates.base || !rates.rates) { - throw new Error('Invalid rates format') + throw new Error('Invalid rates format'); } - return rates -} + return rates; +}; export const getTabId = (sender: Runtime.MessageSender): number => { - return notNullOrUndef(notNullOrUndef(sender.tab, 'sender.tab').id, 'tab.id') -} + 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) => { - const tabId = getTabId(sender) - const frameId = notNullOrUndef(sender.frameId, 'sender.frameId') + const tabId = getTabId(sender); + const frameId = notNullOrUndef(sender.frameId, 'sender.frameId'); - return { tabId, frameId, url: sender.url } -} + 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() + (BigInt(rate) / BigInt(sessionsCount)).toString(); export function computeBalance( grant?: GrantDetails | null, - grantSpentAmount?: AmountValue | null + grantSpentAmount?: AmountValue | null, ) { - if (!grant?.amount) return 0n - const total = BigInt(grant.amount.value) - return grantSpentAmount ? total - BigInt(grantSpentAmount) : total + if (!grant?.amount) return 0n; + const total = BigInt(grant.amount.value); + return grantSpentAmount ? total - BigInt(grantSpentAmount) : total; } // USD Scale 9 (connected wallet) @@ -110,24 +124,24 @@ export function computeBalance( export function* getNextSendableAmount( senderAssetScale: number, receiverAssetScale: number, - amount: bigint = 0n + amount: bigint = 0n, ): Generator { - const EXPONENTIAL_INCREASE = 0.5 + const EXPONENTIAL_INCREASE = 0.5; const scaleDiff = senderAssetScale < receiverAssetScale ? 0 - : senderAssetScale - receiverAssetScale - const base = 1n * 10n ** BigInt(scaleDiff) + : senderAssetScale - receiverAssetScale; + const base = 1n * 10n ** BigInt(scaleDiff); if (amount) { - yield amount.toString() + yield amount.toString(); } - let exp = 0 + let exp = 0; while (true) { - amount += base * BigInt(Math.floor(Math.exp(exp))) - yield amount.toString() - exp += EXPONENTIAL_INCREASE + amount += base * BigInt(Math.floor(Math.exp(exp))); + yield amount.toString(); + exp += EXPONENTIAL_INCREASE; } } diff --git a/src/content/container.ts b/src/content/container.ts index 0362b86c..c5eaf2d8 100644 --- a/src/content/container.ts +++ b/src/content/container.ts @@ -1,49 +1,55 @@ -import { asClass, asValue, createContainer, InjectionMode } from 'awilix' -import browser, { type Browser } from 'webextension-polyfill' -import { createLogger, Logger } from '@/shared/logger' -import { ContentScript } from './services/contentScript' -import { MonetizationTagManager } from './services/monetizationTagManager' -import { LOG_LEVEL } from '@/shared/defines' -import { FrameManager } from './services/frameManager' +import { asClass, asValue, createContainer, InjectionMode } from 'awilix'; +import browser, { type Browser } from 'webextension-polyfill'; +import { createLogger, Logger } from '@/shared/logger'; +import { ContentScript } from './services/contentScript'; +import { MonetizationLinkManager } from './services/monetizationLinkManager'; +import { LOG_LEVEL } from '@/shared/defines'; +import { FrameManager } from './services/frameManager'; +import { + type ContentToBackgroundMessage, + MessageManager, +} from '@/shared/messages'; export interface Cradle { - logger: Logger - browser: Browser - document: Document - window: Window - monetizationTagManager: MonetizationTagManager - frameManager: FrameManager - contentScript: ContentScript + logger: Logger; + browser: Browser; + document: Document; + window: Window; + message: MessageManager; + monetizationLinkManager: MonetizationLinkManager; + frameManager: FrameManager; + contentScript: ContentScript; } export const configureContainer = () => { const container = createContainer({ - injectionMode: InjectionMode.PROXY - }) + injectionMode: InjectionMode.PROXY, + }); - const logger = createLogger(LOG_LEVEL) + const logger = createLogger(LOG_LEVEL); container.register({ logger: asValue(logger), browser: asValue(browser), document: asValue(document), window: asValue(window), + message: asClass(MessageManager).singleton(), frameManager: asClass(FrameManager) .singleton() .inject(() => ({ - logger: logger.getLogger('content-script:frameManager') + logger: logger.getLogger('content-script:frameManager'), })), - monetizationTagManager: asClass(MonetizationTagManager) + monetizationLinkManager: asClass(MonetizationLinkManager) .singleton() .inject(() => ({ - logger: logger.getLogger('content-script:tagManager') + logger: logger.getLogger('content-script:tagManager'), })), contentScript: asClass(ContentScript) .singleton() .inject(() => ({ - logger: logger.getLogger('content-script:main') - })) - }) + logger: logger.getLogger('content-script:main'), + })), + }); - return container -} + return container; +}; diff --git a/src/content/debug.ts b/src/content/debug.ts index 9a50b4bf..5daca846 100644 --- a/src/content/debug.ts +++ b/src/content/debug.ts @@ -11,38 +11,38 @@ const listenForLinkChange = (mutationsList: MutationRecord[]) => { link.getAttribute('href')?.match(/^http/) && link.getAttribute('rel')?.match(/monetization/) ) { - acc.push(link) + acc.push(link); } - return acc + return acc; }, - [] - ) + [], + ); if (monetizationLinks.length) { - console.log(monetizationLinks) + console.log(monetizationLinks); } } if (mutationsList[0].type === 'attributes') { - const target = mutationsList[0].target as HTMLElement + const target = mutationsList[0].target as HTMLElement; if ( target.tagName?.toLowerCase() === 'link' && target.getAttribute('href')?.match(/^http/) && target.getAttribute('rel')?.match(/monetization/) ) { - console.log('LINK ATTR CHANGED', target) + console.log('LINK ATTR CHANGED', target); } } -} +}; export const loadObserver = () => { - const observer = new MutationObserver(listenForLinkChange) + const observer = new MutationObserver(listenForLinkChange); const observeOptions = { attributes: true, childList: true, - subtree: true - } + subtree: true, + }; - observer.observe(document, observeOptions) -} + observer.observe(document, observeOptions); +}; diff --git a/src/content/index.ts b/src/content/index.ts index ef66372c..60234077 100755 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,4 +1,4 @@ -import { configureContainer } from './container' +import { configureContainer } from './container'; -const container = configureContainer() -container.resolve('contentScript').start() +const container = configureContainer(); +container.resolve('contentScript').start(); diff --git a/src/content/keyAutoAdd/lib/helpers.ts b/src/content/keyAutoAdd/lib/helpers.ts new file mode 100644 index 00000000..6b80375b --- /dev/null +++ b/src/content/keyAutoAdd/lib/helpers.ts @@ -0,0 +1,53 @@ +// cSpell:ignore locationchange +/* eslint-disable @typescript-eslint/no-empty-object-type */ + +import { withResolvers } from '@/shared/helpers'; + +interface WaitForOptions { + timeout: number; +} + +interface WaitForURLOptions extends WaitForOptions {} + +export async function waitForURL( + match: (url: URL) => boolean, + { timeout = 10 * 1000 }: Partial = {}, +) { + const { resolve, reject, promise } = withResolvers(); + + if (match(new URL(window.location.href))) { + resolve(true); + return promise; + } + + const abortSignal = AbortSignal.timeout(timeout); + abortSignal.addEventListener('abort', (e) => { + observer.disconnect(); + reject(e); + }); + + let url = window.location.href; + // There's no stable 'locationchange' event, so we assume there's a chance + // when the DOM changes, the URL might have changed too, and we make our URL + // test then. + const observer = new MutationObserver(() => { + if (window.location.href === url) return; + url = window.location.href; + if (match(new URL(url))) { + observer.disconnect(); + resolve(false); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + + return promise; +} + +export function isTimedOut(e: any) { + return ( + e instanceof Event && + e.type === 'abort' && + e.currentTarget instanceof AbortSignal && + e.currentTarget.reason?.name === 'TimeoutError' + ); +} diff --git a/src/content/keyAutoAdd/lib/keyAutoAdd.ts b/src/content/keyAutoAdd/lib/keyAutoAdd.ts new file mode 100644 index 00000000..94cb2ea4 --- /dev/null +++ b/src/content/keyAutoAdd/lib/keyAutoAdd.ts @@ -0,0 +1,217 @@ +// cSpell:ignore allowtransparency +import browser, { type Runtime } from 'webextension-polyfill'; +import { CONNECTION_NAME } from '@/background/services/keyAutoAdd'; +import { + errorWithKeyToJSON, + isErrorWithKey, + sleep, + withResolvers, + type ErrorWithKeyLike, +} from '@/shared/helpers'; +import type { + BackgroundToKeyAutoAddMessage, + BeginPayload, + Details, + KeyAutoAddToBackgroundMessage, + KeyAutoAddToBackgroundMessagesMap, + Step, + StepRun, + StepRunHelpers, + StepWithStatus, +} from './types'; + +export type { StepRun } from './types'; + +export const LOGIN_WAIT_TIMEOUT = 10 * 60 * 1000; + +export class KeyAutoAdd { + private port: Runtime.Port; + private ui: HTMLIFrameElement; + + private stepsInput: Map; + private steps: StepWithStatus[]; + private outputs = new Map(); + + constructor(steps: Step[]) { + this.stepsInput = new Map(steps.map((step) => [step.name, step])); + this.steps = steps.map((step) => ({ name: step.name, status: 'pending' })); + } + + init() { + this.port = browser.runtime.connect({ name: CONNECTION_NAME }); + + this.port.onMessage.addListener( + (message: BackgroundToKeyAutoAddMessage) => { + if (message.action === 'BEGIN') { + this.runAll(message.payload); + } + }, + ); + } + + private setNotificationSize(size: 'notification' | 'fullscreen' | 'hidden') { + let styles: Partial; + const defaultStyles: Partial = { + outline: 'none', + border: 'none', + zIndex: '9999', + position: 'fixed', + top: '0', + left: '0', + }; + + if (size === 'notification') { + styles = { + width: '22rem', + height: '8rem', + position: 'fixed', + top: '1rem', + right: '1rem', + left: 'initial', + boxShadow: 'rgba(0, 0, 0, 0.1) 0px 0px 6px 3px', + borderRadius: '0.5rem', + }; + } else if (size === 'fullscreen') { + styles = { + width: '100vw', + height: '100vh', + }; + } else { + styles = { + width: '0', + height: '0', + position: 'absolute', + }; + } + + this.ui.style.cssText = ''; + Object.assign(this.ui.style, defaultStyles); + Object.assign(this.ui.style, styles); + + const iframeUrl = new URL( + browser.runtime.getURL('pages/progress-connect/index.html'), + ); + const params = new URLSearchParams({ mode: size }); + iframeUrl.hash = '?' + params.toString(); + if (this.ui.src !== iframeUrl.href && size !== 'hidden') { + this.ui.src = iframeUrl.href; + } + } + + private addNotification() { + const { resolve, reject, promise } = withResolvers(); + if (this.ui) { + resolve(); + return promise; + } + const pageUrl = browser.runtime.getURL('pages/progress-connect/index.html'); + const iframe = document.createElement('iframe'); + iframe.setAttribute('allowtransparency', 'true'); + iframe.src = pageUrl; + document.body.appendChild(iframe); + iframe.addEventListener('load', () => { + resolve(); + sleep(500).then(() => + this.postMessage('PROGRESS', { steps: this.steps }), + ); + }); + iframe.addEventListener('error', reject, { once: true }); + this.ui = iframe; + this.setNotificationSize('hidden'); + return promise; + } + + private async runAll(payload: BeginPayload) { + const helpers: StepRunHelpers = { + output: (fn: T) => { + if (!this.outputs.has(fn)) { + // Was never run? Was skipped? + throw new Error('Given step has no output'); + } + return this.outputs.get(fn) as Awaited>; + }, + skip: (details) => { + throw new SkipError( + typeof details === 'string' ? { message: details } : details, + ); + }, + setNotificationSize: (size: 'notification' | 'fullscreen') => { + this.setNotificationSize(size); + }, + }; + + await this.addNotification(); + this.postMessage('PROGRESS', { steps: this.steps }); + + for (let stepIdx = 0; stepIdx < this.steps.length; stepIdx++) { + const step = this.steps[stepIdx]; + const stepInfo = this.stepsInput.get(step.name)!; + this.setStatus(stepIdx, 'active', { + expiresAt: stepInfo.maxDuration + ? new Date(Date.now() + stepInfo.maxDuration).valueOf() + : undefined, + }); + try { + const run = this.stepsInput.get(step.name)!.run; + const res = await run(payload, helpers); + this.outputs.set(run, res); + this.setStatus(stepIdx, 'success', {}); + } catch (error) { + if (error instanceof SkipError) { + const details = error.toJSON(); + this.setStatus(stepIdx, 'skipped', { details }); + continue; + } + const details = errorToDetails(error); + this.setStatus(stepIdx, 'error', { details: details }); + this.postMessage('ERROR', { details, stepName: step.name, stepIdx }); + this.port.disconnect(); + return; + } + } + this.postMessage('PROGRESS', { steps: this.steps }); + this.postMessage('SUCCESS', true); + this.port.disconnect(); + } + + private postMessage( + action: T, + payload: KeyAutoAddToBackgroundMessagesMap[T], + ) { + const message = { action, payload } as KeyAutoAddToBackgroundMessage; + this.port.postMessage(message); + } + + private setStatus( + stepIdx: number, + status: T, + data: Omit, 'name' | 'status'>, + ) { + // @ts-expect-error what's missing is part of data, TypeScript! + this.steps[stepIdx] = { + name: this.steps[stepIdx].name, + status, + ...data, + }; + this.postMessage('PROGRESS', { steps: this.steps }); + } +} + +class SkipError extends Error { + public readonly error?: ErrorWithKeyLike; + constructor(err: ErrorWithKeyLike | { message: string }) { + const { message, error } = errorToDetails(err); + super(message); + this.error = error; + } + + toJSON(): Details { + return { message: this.message, error: this.error }; + } +} + +function errorToDetails(err: { message: string } | ErrorWithKeyLike): Details { + return isErrorWithKey(err) + ? { error: errorWithKeyToJSON(err), message: err.key } + : { message: err.message as string }; +} diff --git a/src/content/keyAutoAdd/lib/types.ts b/src/content/keyAutoAdd/lib/types.ts new file mode 100644 index 00000000..76906df7 --- /dev/null +++ b/src/content/keyAutoAdd/lib/types.ts @@ -0,0 +1,83 @@ +import type { ErrorWithKeyLike } from '@/shared/helpers'; +import type { ErrorResponse } from '@/shared/messages'; + +export interface StepRunHelpers { + skip: (message: string | Error | ErrorWithKeyLike) => never; + setNotificationSize: (size: 'notification' | 'fullscreen') => void; + output: (fn: T) => Awaited>; +} + +export type StepRun = ( + payload: BeginPayload, + helpers: StepRunHelpers, +) => Promise; + +export interface Step { + name: string; + run: StepRun; + maxDuration?: number; +} + +export type Details = Omit; + +interface StepWithStatusBase { + name: Step['name']; + status: string; +} +interface StepWithStatusNormal extends StepWithStatusBase { + status: 'pending' | 'success'; +} +interface StepWithStatusActive extends StepWithStatusBase { + status: 'active'; + expiresAt?: number; +} +interface StepWithStatusSkipped extends StepWithStatusBase { + status: 'skipped'; + details: Details; +} +interface StepWithStatusError extends StepWithStatusBase { + status: 'error'; + details: Details; +} + +export type StepWithStatus = + | StepWithStatusNormal + | StepWithStatusActive + | StepWithStatusSkipped + | StepWithStatusError; + +export interface BeginPayload { + walletAddressUrl: string; + publicKey: string; + keyId: string; + nickName: string; + keyAddUrl: string; +} + +export type BackgroundToKeyAutoAddMessagesMap = { + BEGIN: BeginPayload; +}; + +export type BackgroundToKeyAutoAddMessage = { + [K in keyof BackgroundToKeyAutoAddMessagesMap]: { + action: K; + payload: BackgroundToKeyAutoAddMessagesMap[K]; + }; +}[keyof BackgroundToKeyAutoAddMessagesMap]; + +export type KeyAutoAddToBackgroundMessagesMap = { + PROGRESS: { steps: StepWithStatus[] }; + SUCCESS: true; + ERROR: { + stepIdx: number; + stepName: StepWithStatus['name']; + details: Details; + }; +}; + +export type KeyAutoAddToBackgroundMessage = { + [K in keyof KeyAutoAddToBackgroundMessagesMap]: { + action: K; + payload: KeyAutoAddToBackgroundMessagesMap[K]; + }; +}[keyof KeyAutoAddToBackgroundMessagesMap]; diff --git a/src/content/keyAutoAdd/testWallet.ts b/src/content/keyAutoAdd/testWallet.ts new file mode 100644 index 00000000..ae5698a5 --- /dev/null +++ b/src/content/keyAutoAdd/testWallet.ts @@ -0,0 +1,178 @@ +// cSpell:ignore nextjs +import { errorWithKey, ErrorWithKey, sleep } from '@/shared/helpers'; +import { + KeyAutoAdd, + LOGIN_WAIT_TIMEOUT, + type StepRun as Run, +} from './lib/keyAutoAdd'; +import { isTimedOut, waitForURL } from './lib/helpers'; +import { toWalletAddressUrl } from '@/popup/lib/utils'; + +// region: Steps +type Account = { + id: string; + walletAddresses: { + id: string; + url: string; + keys: { + id: string; + }[]; + }[]; +}; + +type AccountDetails = { + pageProps: { + accounts: Account[]; + }; +}; + +const waitForLogin: Run = async ( + { keyAddUrl }, + { skip, setNotificationSize }, +) => { + let alreadyLoggedIn = window.location.href.startsWith(keyAddUrl); + if (!alreadyLoggedIn) setNotificationSize('notification'); + try { + alreadyLoggedIn = await waitForURL( + (url) => (url.origin + url.pathname).startsWith(keyAddUrl), + { timeout: LOGIN_WAIT_TIMEOUT }, + ); + setNotificationSize('fullscreen'); + } catch (error) { + if (isTimedOut(error)) { + throw new ErrorWithKey('connectWalletKeyService_error_timeoutLogin'); + } + throw new Error(error); + } + + if (alreadyLoggedIn) { + skip(errorWithKey('connectWalletKeyService_error_skipAlreadyLoggedIn')); + } +}; + +const getAccounts: Run = async (_, { setNotificationSize }) => { + setNotificationSize('fullscreen'); + await sleep(1000); + + const NEXT_DATA = document.querySelector('script#__NEXT_DATA__')?.textContent; + if (!NEXT_DATA) { + throw new Error('Failed to find `__NEXT_DATA__` script'); + } + let buildId: string; + try { + buildId = JSON.parse(NEXT_DATA).buildId; + if (!buildId || typeof buildId !== 'string') { + throw new Error('Failed to get buildId from `__NEXT_DATA__` script'); + } + } catch (error) { + throw new Error('Failed to parse `__NEXT_DATA__` script', { + cause: error, + }); + } + + const url = `https://rafiki.money/_next/data/${buildId}/settings/developer-keys.json`; + const res = await fetch(url, { + method: 'GET', + mode: 'cors', + credentials: 'include', + }); + const json: AccountDetails = await res.json(); + return json.pageProps.accounts; +}; + +/** + * The test wallet associates key with an account. If the same key is associated + * with a different account (user disconnected and changed account), revoke from + * there first. + * + * Why? Say, user connected once to `USD -> Addr#1`. Then disconnected. The key + * is still there in wallet added to `USD -> Addr#1` account. If now user wants + * to connect `EUR -> Addr#2` account, the extension still has the same key. So + * adding it again will throw an `internal server error`. But we'll continue + * getting `invalid_client` if we try to connect without the key added to new + * address. That's why we first revoke existing key (by ID) if any (from any + * existing account/address). It's a test-wallet specific thing. + */ +const revokeExistingKey: Run = async ({ keyId }, { skip, output }) => { + const accounts = output(getAccounts); + for (const account of accounts) { + for (const wallet of account.walletAddresses) { + for (const key of wallet.keys) { + if (key.id === keyId) { + await revokeKey(account.id, wallet.id, key.id); + } + } + } + } + + skip('No existing keys that need to be revoked'); +}; + +const findWallet: Run<{ accountId: string; walletId: string }> = async ( + { walletAddressUrl }, + { output }, +) => { + const accounts = output(getAccounts); + for (const account of accounts) { + for (const wallet of account.walletAddresses) { + if (toWalletAddressUrl(wallet.url) === walletAddressUrl) { + return { accountId: account.id, walletId: wallet.id }; + } + } + } + throw new ErrorWithKey('connectWalletKeyService_error_accountNotFound'); +}; + +const addKey: Run = async ({ publicKey, nickName }, { output }) => { + const { accountId, walletId } = output(findWallet); + const url = `https://api.rafiki.money/accounts/${accountId}/wallet-addresses/${walletId}/upload-key`; + const res = await fetch(url, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + base64Key: publicKey, + nickname: nickName, + }), + mode: 'cors', + credentials: 'include', + }); + if (!res.ok) { + throw new Error(await res.text()); + } +}; +// endregion + +// region: Helpers +async function revokeKey(accountId: string, walletId: string, keyId: string) { + const url = `https://api.rafiki.money/accounts/${accountId}/wallet-addresses/${walletId}/${keyId}/revoke-key/`; + const res = await fetch(url, { + method: 'PATCH', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + mode: 'cors', + credentials: 'include', + }); + if (!res.ok) { + throw new Error(`Failed to revoke key: ${await res.text()}`); + } +} +// endregion + +// region: Main +new KeyAutoAdd([ + { + name: 'Waiting for you to login', + run: waitForLogin, + maxDuration: LOGIN_WAIT_TIMEOUT, + }, + { name: 'Getting account details', run: getAccounts }, + { name: 'Revoking existing key', run: revokeExistingKey }, + { name: 'Finding wallet', run: findWallet }, + { name: 'Adding key', run: addKey }, +]).init(); +// endregion diff --git a/src/content/lib/messages.ts b/src/content/lib/messages.ts deleted file mode 100644 index f40284f4..00000000 --- a/src/content/lib/messages.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - MessageManager, - ContentToBackgroundAction, - ContentToBackgroundActionPayload, - type ContentToBackgroundMessage -} from '@/shared/messages' -import { WalletAddress } from '@interledger/open-payments/dist/types' -import browser from 'webextension-polyfill' - -export const message = new MessageManager(browser) - -export const checkWalletAddressUrlCall = async ( - payload: ContentToBackgroundActionPayload[ContentToBackgroundAction.CHECK_WALLET_ADDRESS_URL] -) => { - return await message.send({ - action: ContentToBackgroundAction.CHECK_WALLET_ADDRESS_URL, - payload - }) -} - -export const startMonetization = async ( - payload: ContentToBackgroundActionPayload[ContentToBackgroundAction.START_MONETIZATION] -) => { - if (!payload.length) return - return await message.send({ - action: ContentToBackgroundAction.START_MONETIZATION, - payload - }) -} - -export const stopMonetization = async ( - payload: ContentToBackgroundActionPayload[ContentToBackgroundAction.STOP_MONETIZATION] -) => { - if (!payload.length) return - return await message.send({ - action: ContentToBackgroundAction.STOP_MONETIZATION, - payload - }) -} - -export const resumeMonetization = async ( - payload: ContentToBackgroundActionPayload[ContentToBackgroundAction.RESUME_MONETIZATION] -) => { - if (!payload.length) return - return await message.send({ - action: ContentToBackgroundAction.RESUME_MONETIZATION, - payload - }) -} - -export const isWMEnabled = async () => { - return await message.send({ - action: ContentToBackgroundAction.IS_WM_ENABLED - }) -} diff --git a/src/content/messages.ts b/src/content/messages.ts index 1d745383..c2657404 100644 --- a/src/content/messages.ts +++ b/src/content/messages.ts @@ -1,9 +1,23 @@ -export enum ContentToContentAction { - INITIALIZE_IFRAME = 'INITIALIZE_IFRAME', - IS_MONETIZATION_ALLOWED_ON_START = 'IS_MONETIZATION_ALLOWED_ON_START', - IS_MONETIZATION_ALLOWED_ON_RESUME = 'IS_MONETIZATION_ALLOWED_ON_RESUME', - IS_MONETIZATION_ALLOWED_ON_STOP = 'IS_MONETIZATION_ALLOWED_ON_STOP', - START_MONETIZATION = 'START_MONETIZATION', - STOP_MONETIZATION = 'STOP_MONETIZATION', - RESUME_MONETIZATION = 'RESUME_MONETIZATION' +import type { + ResumeMonetizationPayload, + StartMonetizationPayload, + StopMonetizationPayload, +} from '@/shared/messages'; + +export interface ContentToContentMessageMap { + INITIALIZE_IFRAME: void; + IS_MONETIZATION_ALLOWED_ON_START: StartMonetizationPayload; + IS_MONETIZATION_ALLOWED_ON_RESUME: ResumeMonetizationPayload; + IS_MONETIZATION_ALLOWED_ON_STOP: StopMonetizationPayload; + START_MONETIZATION: StartMonetizationPayload; + STOP_MONETIZATION: StopMonetizationPayload; + RESUME_MONETIZATION: ResumeMonetizationPayload; } + +export type ContentToContentMessage = { + [K in keyof ContentToContentMessageMap]: { + message: K; + id: string; + payload: ContentToContentMessageMap[K]; + }; +}[keyof ContentToContentMessageMap]; diff --git a/src/content/polyfill.ts b/src/content/polyfill.ts index 68ece957..ec12fd5c 100644 --- a/src/content/polyfill.ts +++ b/src/content/polyfill.ts @@ -1,125 +1,130 @@ -import type { MonetizationEventPayload } from '@/shared/messages' -;(function () { - const handlers = new WeakMap() +import type { MonetizationEventPayload } from '@/shared/messages'; +(function () { + if (document.createElement('link').relList.supports('monetization')) { + // already patched + return; + } + + const handlers = new WeakMap(); const attributes: PropertyDescriptor & ThisType = { enumerable: true, configurable: false, get() { - return handlers.get(this) || null + return handlers.get(this) || null; }, set(val) { - const listener = handlers.get(this) + const listener = handlers.get(this); if (listener && listener === val) { // nothing to do here ? - return + return; } const removeAnyExisting = () => { if (listener) { - this.removeEventListener('monetization', listener) + this.removeEventListener('monetization', listener); } - } + }; if (val == null /* OR undefined*/) { - handlers.delete(this) - removeAnyExisting() + handlers.delete(this); + removeAnyExisting(); } else if (typeof val === 'function') { - removeAnyExisting() - this.addEventListener('monetization', val) - handlers.set(this, val) + removeAnyExisting(); + this.addEventListener('monetization', val); + handlers.set(this, val); } else { - throw new Error('val must be a function, got ' + typeof val) + throw new Error('val must be a function, got ' + typeof val); } - } - } + }, + }; - const supportsOriginal = DOMTokenList.prototype.supports - const supportsMonetization = Symbol.for('link-supports-monetization') + 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 + return true; } else { - return supportsOriginal.call(this, token) + return supportsOriginal.call(this, token); } - } + }; const relList = Object.getOwnPropertyDescriptor( HTMLLinkElement.prototype, - 'relList' - )! - const relListGetOriginal = relList.get! + 'relList', + )!; + const relListGetOriginal = relList.get!; relList.get = function () { - const val = relListGetOriginal.call(this) - val[supportsMonetization] = true - return val - } + 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) + 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 + let eventDetailDeprecationEmitted = false; class MonetizationEvent extends Event { - public readonly amountSent: PaymentCurrencyAmount - public readonly incomingPayment: string - public readonly paymentPointer: string + public readonly amountSent: PaymentCurrencyAmount; + public readonly incomingPayment: string; + public readonly paymentPointer: string; constructor( type: 'monetization', - eventInitDict: MonetizationEventPayload['details'] + eventInitDict: MonetizationEventPayload['details'], ) { - super(type, { bubbles: true }) - const { amountSent, incomingPayment, paymentPointer } = eventInitDict - this.amountSent = amountSent - this.incomingPayment = incomingPayment - this.paymentPointer = paymentPointer + super(type, { bubbles: true }); + const { amountSent, incomingPayment, paymentPointer } = eventInitDict; + this.amountSent = amountSent; + this.incomingPayment = incomingPayment; + this.paymentPointer = paymentPointer; } get [Symbol.toStringTag]() { - return 'MonetizationEvent' + return 'MonetizationEvent'; } get detail() { if (!eventDetailDeprecationEmitted) { - const msg = `MonetizationEvent.detail is deprecated. Access attributes directly instead.` + const msg = `MonetizationEvent.detail is deprecated. Access attributes directly instead.`; // eslint-disable-next-line no-console - console.warn(msg) - eventDetailDeprecationEmitted = true + console.warn(msg); + eventDetailDeprecationEmitted = true; } - const { amountSent, incomingPayment, paymentPointer } = this - return { amountSent, incomingPayment, paymentPointer } + const { amountSent, incomingPayment, paymentPointer } = this; + return { amountSent, incomingPayment, paymentPointer }; } } // @ts-expect-error: we're defining this now - window.MonetizationEvent = MonetizationEvent + window.MonetizationEvent = MonetizationEvent; window.addEventListener( '__wm_ext_monetization', (event: CustomEvent) => { - if (!(event.target instanceof HTMLLinkElement)) return - if (!event.target.isConnected) return + if (!(event.target instanceof HTMLLinkElement)) return; + if (!event.target.isConnected) return; - const monetizationTag = event.target + const monetizationTag = event.target; monetizationTag.dispatchEvent( - new MonetizationEvent('monetization', event.detail) - ) + new MonetizationEvent('monetization', event.detail), + ); }, - { capture: true } - ) + { capture: true }, + ); window.addEventListener( '__wm_ext_onmonetization_attr_change', (event: CustomEvent<{ attribute?: string }>) => { - if (!event.target) return + if (!event.target) return; - const { attribute } = event.detail + const { attribute } = event.detail; // @ts-expect-error: we're defining this now event.target.onmonetization = attribute ? new Function(attribute).bind(event.target) - : null + : null; }, - { capture: true } - ) -})() + { capture: true }, + ); +})(); diff --git a/src/content/services/contentScript.ts b/src/content/services/contentScript.ts index 3866eabf..7a7cf996 100644 --- a/src/content/services/contentScript.ts +++ b/src/content/services/contentScript.ts @@ -1,49 +1,46 @@ -import { - BackgroundToContentAction, - type ToContentMessage -} from '@/shared/messages' -import { failure } from '@/shared/helpers' -import type { Cradle } from '@/content/container' +import type { ToContentMessage } from '@/shared/messages'; +import type { Cradle } from '@/content/container'; +import { failure, success } from '@/shared/helpers'; export class ContentScript { - private browser: Cradle['browser'] - private window: Cradle['window'] - private logger: Cradle['logger'] - private monetizationTagManager: Cradle['monetizationTagManager'] - private frameManager: Cradle['frameManager'] + private browser: Cradle['browser']; + private window: Cradle['window']; + private logger: Cradle['logger']; + private monetizationLinkManager: Cradle['monetizationLinkManager']; + private frameManager: Cradle['frameManager']; - private isFirstLevelFrame: boolean - private isTopFrame: boolean + private isFirstLevelFrame: boolean; + private isTopFrame: boolean; constructor({ browser, window, logger, - monetizationTagManager, - frameManager + monetizationLinkManager, + frameManager, }: Cradle) { Object.assign(this, { browser, window, logger, - monetizationTagManager, - frameManager - }) + monetizationLinkManager, + frameManager, + }); - this.isTopFrame = window === window.top - this.isFirstLevelFrame = window.parent === window.top + this.isTopFrame = window === window.top; + this.isFirstLevelFrame = window.parent === window.top; - this.bindMessageHandler() + this.bindMessageHandler(); } async start() { - await this.injectPolyfill() + await this.injectPolyfill(); if (this.isFirstLevelFrame) { - this.logger.info('Content script started') + this.logger.info('Content script started'); - if (this.isTopFrame) this.frameManager.start() + if (this.isTopFrame) this.frameManager.start(); - this.monetizationTagManager.start() + this.monetizationLinkManager.start(); } } @@ -52,38 +49,36 @@ export class ContentScript { async (message: ToContentMessage) => { try { switch (message.action) { - case BackgroundToContentAction.MONETIZATION_EVENT: - this.monetizationTagManager.dispatchMonetizationEvent( - message.payload - ) - return - - case BackgroundToContentAction.EMIT_TOGGLE_WM: - this.monetizationTagManager.toggleWM(message.payload) - - return - + case 'MONETIZATION_EVENT': + this.monetizationLinkManager.dispatchMonetizationEvent( + message.payload, + ); + return; + case 'IS_TAB_IN_VIEW': + return success(document.visibilityState === 'visible'); default: - return + return; } } catch (e) { - this.logger.error(message.action, e.message) - return failure(e.message) + this.logger.error(message.action, e.message); + return failure(e.message); } - } - ) + }, + ); } // TODO: When Firefox has good support for `world: MAIN`, inject this directly - // via manifest.json https://bugzilla.mozilla.org/show_bug.cgi?id=1736575 + // via manifest.json https://bugzilla.mozilla.org/show_bug.cgi?id=1736575 and + // remove this, along with injectPolyfill from background + // See: https://github.com/interledger/web-monetization-extension/issues/607 async injectPolyfill() { - const document = this.window.document - const script = document.createElement('script') - script.src = this.browser.runtime.getURL('polyfill/polyfill.js') + 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() + script.addEventListener('load', () => resolve(), { once: true }); + document.documentElement.appendChild(script); + }); + script.remove(); } } diff --git a/src/content/services/frameManager.ts b/src/content/services/frameManager.ts index f2cb8429..68b5835e 100644 --- a/src/content/services/frameManager.ts +++ b/src/content/services/frameManager.ts @@ -1,91 +1,98 @@ -import { stopMonetization } from '../lib/messages' -import { ContentToContentAction } from '../messages' +import type { ContentToContentMessage } from '../messages'; import type { - ResumeMonetizationPayload, - StartMonetizationPayload, - StopMonetizationPayload -} from '@/shared/messages' -import type { Cradle } from '@/content/container' + ResumeMonetizationPayloadEntry, + StartMonetizationPayloadEntry, + StopMonetizationPayload, +} from '@/shared/messages'; +import type { Cradle } from '@/content/container'; + +const HANDLED_MESSAGES: ContentToContentMessage['message'][] = [ + 'INITIALIZE_IFRAME', + 'IS_MONETIZATION_ALLOWED_ON_START', + 'IS_MONETIZATION_ALLOWED_ON_RESUME', +]; export class FrameManager { - private window: Cradle['window'] - private document: Cradle['document'] - private logger: Cradle['logger'] + private window: Cradle['window']; + private document: Cradle['document']; + private logger: Cradle['logger']; + private message: Cradle['message']; - private documentObserver: MutationObserver - private frameAllowAttrObserver: MutationObserver + private documentObserver: MutationObserver; + private frameAllowAttrObserver: MutationObserver; private frames = new Map< HTMLIFrameElement, { frameId: string | null; requestIds: string[] } - >() + >(); - constructor({ window, document, logger }: Cradle) { + constructor({ window, document, logger, message }: Cradle) { Object.assign(this, { window, document, - logger - }) + logger, + message, + }); this.documentObserver = new MutationObserver((records) => - this.onWholeDocumentObserved(records) - ) + this.onWholeDocumentObserved(records), + ); this.frameAllowAttrObserver = new MutationObserver((records) => - this.onFrameAllowAttrChange(records) - ) + this.onFrameAllowAttrChange(records), + ); } private findIframe(sourceWindow: Window): HTMLIFrameElement | null { - const iframes = this.frames.keys() - let frame + const iframes = this.frames.keys(); + let frame; do { - frame = iframes.next() - if (frame.done) return null - if (frame.value.contentWindow === sourceWindow) return frame.value - } while (!frame.done) + frame = iframes.next(); + if (frame.done) return null; + if (frame.value.contentWindow === sourceWindow) return frame.value; + } while (!frame.done); - return null + return null; } private observeDocumentForFrames() { this.documentObserver.observe(this.document, { subtree: true, - childList: true - }) + childList: true, + }); } private observeFrameAllowAttrs(frame: HTMLIFrameElement) { this.frameAllowAttrObserver.observe(frame, { childList: false, attributeOldValue: true, - attributeFilter: ['allow'] - }) + attributeFilter: ['allow'], + }); } async onFrameAllowAttrChange(records: MutationRecord[]) { - const handledTags = new Set() + const handledTags = new Set(); // Check for a non specified link with the type now specified and // just treat it as a newly seen, monetization tag for (const record of records) { - const target = record.target as HTMLIFrameElement + const target = record.target as HTMLIFrameElement; if (handledTags.has(target)) { - continue + continue; } - const hasTarget = this.frames.has(target) + const hasTarget = this.frames.has(target); const typeSpecified = - target instanceof HTMLIFrameElement && target.allow === 'monetization' + target instanceof HTMLIFrameElement && target.allow === 'monetization'; if (!hasTarget && typeSpecified) { - await this.onAddedFrame(target) - handledTags.add(target) + await this.onAddedFrame(target); + handledTags.add(target); } else if (hasTarget && !typeSpecified) { - this.onRemovedFrame(target) - handledTags.add(target) + this.onRemovedFrame(target); + handledTags.add(target); } else if (!hasTarget && !typeSpecified) { // ignore these changes - handledTags.add(target) + handledTags.add(target); } } } @@ -93,35 +100,37 @@ export class FrameManager { private async onAddedFrame(frame: HTMLIFrameElement) { this.frames.set(frame, { frameId: null, - requestIds: [] - }) + requestIds: [], + }); } private async onRemovedFrame(frame: HTMLIFrameElement) { - this.logger.info('onRemovedFrame', frame) + this.logger.info('onRemovedFrame', frame); - const frameDetails = this.frames.get(frame) + const frameDetails = this.frames.get(frame); - const stopMonetizationTags: StopMonetizationPayload[] = + const stopMonetizationTags: StopMonetizationPayload = frameDetails?.requestIds.map((requestId) => ({ requestId, - intent: 'remove' - })) || [] - stopMonetization(stopMonetizationTags) + intent: 'remove', + })) || []; + if (stopMonetizationTags.length) { + this.message.send('STOP_MONETIZATION', stopMonetizationTags); + } - this.frames.delete(frame) + this.frames.delete(frame); } private onWholeDocumentObserved(records: MutationRecord[]) { for (const record of records) { if (record.type === 'childList') { - record.removedNodes.forEach((node) => this.check('removed', node)) + record.removedNodes.forEach((node) => this.check('removed', node)); } } for (const record of records) { if (record.type === 'childList') { - record.addedNodes.forEach((node) => this.check('added', node)) + record.addedNodes.forEach((node) => this.check('added', node)); } } } @@ -129,127 +138,114 @@ export class FrameManager { async check(op: string, node: Node) { if (node instanceof HTMLIFrameElement) { if (op === 'added') { - this.observeFrameAllowAttrs(node) - await this.onAddedFrame(node) + this.observeFrameAllowAttrs(node); + await this.onAddedFrame(node); } else if (op === 'removed' && this.frames.has(node)) { - this.onRemovedFrame(node) + this.onRemovedFrame(node); } } } start(): void { - this.bindMessageHandler() + this.bindMessageHandler(); if ( document.readyState === 'interactive' || document.readyState === 'complete' ) - this.run() + this.run(); document.addEventListener( 'readystatechange', () => { if (document.readyState === 'interactive') { - this.run() + this.run(); } }, - { once: true } - ) + { once: true }, + ); } private run() { const frames: NodeListOf = - this.document.querySelectorAll('iframe') + this.document.querySelectorAll('iframe'); frames.forEach(async (frame) => { try { - this.observeFrameAllowAttrs(frame) - await this.onAddedFrame(frame) + this.observeFrameAllowAttrs(frame); + await this.onAddedFrame(frame); } catch (e) { - this.logger.error(e) + this.logger.error(e); } - }) + }); - this.observeDocumentForFrames() + this.observeDocumentForFrames(); } private bindMessageHandler() { this.window.addEventListener( 'message', - (event: any) => { - const { message, payload, id } = event.data - if ( - ![ - ContentToContentAction.INITIALIZE_IFRAME, - ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_START, - ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_RESUME - ].includes(message) - ) { - return + (event: MessageEvent) => { + const { message, payload, id } = event.data; + if (!HANDLED_MESSAGES.includes(message)) { + return; } - const frame = this.findIframe(event.source) + const eventSource = event.source as Window; + const frame = this.findIframe(eventSource); if (!frame) { - event.stopPropagation() - return + event.stopPropagation(); + return; } - if (event.origin === this.window.location.href) return + if (event.origin === this.window.location.href) return; switch (message) { - case ContentToContentAction.INITIALIZE_IFRAME: - event.stopPropagation() + case 'INITIALIZE_IFRAME': + event.stopPropagation(); this.frames.set(frame, { frameId: id, - requestIds: [] - }) - return + requestIds: [], + }); + return; - case ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_START: - event.stopPropagation() + case 'IS_MONETIZATION_ALLOWED_ON_START': + event.stopPropagation(); if (frame.allow === 'monetization') { this.frames.set(frame, { frameId: id, requestIds: payload.map( - (p: StartMonetizationPayload) => p.requestId - ) - }) - event.source.postMessage( - { - message: ContentToContentAction.START_MONETIZATION, - id, - payload - }, - '*' - ) + (p: StartMonetizationPayloadEntry) => p.requestId, + ), + }); + eventSource.postMessage( + { message: 'START_MONETIZATION', id, payload }, + '*', + ); } - return + return; - case ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_RESUME: - event.stopPropagation() + case 'IS_MONETIZATION_ALLOWED_ON_RESUME': + event.stopPropagation(); if (frame.allow === 'monetization') { this.frames.set(frame, { frameId: id, requestIds: payload.map( - (p: ResumeMonetizationPayload) => p.requestId - ) - }) - event.source.postMessage( - { - message: ContentToContentAction.RESUME_MONETIZATION, - id, - payload - }, - '*' - ) + (p: ResumeMonetizationPayloadEntry) => p.requestId, + ), + }); + eventSource.postMessage( + { message: 'RESUME_MONETIZATION', id, payload }, + '*', + ); } - return + return; default: - return + return; } }, - { capture: true } - ) + { capture: true }, + ); } } diff --git a/src/content/services/monetizationLinkManager.ts b/src/content/services/monetizationLinkManager.ts new file mode 100644 index 00000000..2a8ebcd4 --- /dev/null +++ b/src/content/services/monetizationLinkManager.ts @@ -0,0 +1,530 @@ +import { EventEmitter } from 'events'; +import { isNotNull } from '@/shared/helpers'; +import { mozClone, WalletAddressFormatError } from '../utils'; +import type { WalletAddress } from '@interledger/open-payments/dist/types'; +import type { + MonetizationEventPayload, + ResumeMonetizationPayload, + StartMonetizationPayload, + StopMonetizationPayload, + StopMonetizationPayloadEntry, +} from '@/shared/messages'; +import type { Cradle } from '@/content/container'; +import type { ContentToContentMessage } from '../messages'; + +export class MonetizationLinkManager extends EventEmitter { + private window: Cradle['window']; + private document: Cradle['document']; + private logger: Cradle['logger']; + private message: Cradle['message']; + + private isTopFrame: boolean; + private isFirstLevelFrame: boolean; + private documentObserver: MutationObserver; + private monetizationLinkAttrObserver: MutationObserver; + private id: string; + // only entries corresponding to valid wallet addresses are here + private monetizationLinks = new Map< + HTMLLinkElement, + { walletAddress: WalletAddress; requestId: string } + >(); + + constructor({ window, document, logger, message }: Cradle) { + super(); + Object.assign(this, { + window, + document, + logger, + message, + }); + + this.documentObserver = new MutationObserver((records) => + this.onWholeDocumentObserved(records), + ); + + this.monetizationLinkAttrObserver = new MutationObserver((records) => + this.onLinkAttrChange(records), + ); + + this.isTopFrame = window === window.top; + this.isFirstLevelFrame = window.parent === window.top; + this.id = crypto.randomUUID(); + } + + start(): void { + const isDocumentReady = () => { + const doc = this.document; + return ( + (doc.readyState === 'interactive' || doc.readyState === 'complete') && + doc.visibilityState === 'visible' + ); + }; + + if (isDocumentReady()) { + void this.run(); + return; + } + + document.addEventListener( + 'readystatechange', + () => { + if (isDocumentReady()) { + void this.run(); + } else { + document.addEventListener( + 'visibilitychange', + () => { + if (isDocumentReady()) { + void this.run(); + } + }, + { once: true }, + ); + } + }, + { once: true }, + ); + } + + end() { + this.documentObserver.disconnect(); + this.monetizationLinkAttrObserver.disconnect(); + this.monetizationLinks.clear(); + this.document.removeEventListener( + 'visibilitychange', + this.onDocumentVisibilityChange, + ); + this.window.removeEventListener('message', this.onWindowMessage); + this.window.removeEventListener('focus', this.onFocus); + } + + /** + * Check if iframe or not + */ + private async run() { + this.document.addEventListener( + 'visibilitychange', + this.onDocumentVisibilityChange, + ); + this.onFocus(); + this.window.addEventListener('focus', this.onFocus); + + if (!this.isTopFrame && this.isFirstLevelFrame) { + this.window.addEventListener('message', this.onWindowMessage); + this.postMessage('INITIALIZE_IFRAME', undefined); + } + + this.document + .querySelectorAll('[onmonetization]') + .forEach((node) => { + this.dispatchOnMonetizationAttrChangedEvent(node); + }); + + this.documentObserver.observe(this.document, { + subtree: true, + childList: true, + attributeFilter: ['onmonetization'], + }); + + const monetizationLinks = this.getMonetizationLinkTags(); + + for (const link of monetizationLinks) { + this.observeLinkAttrs(link); + } + + const validLinks = ( + await Promise.all(monetizationLinks.map((elem) => this.checkLink(elem))) + ).filter(isNotNull); + + for (const { link, details } of validLinks) { + this.monetizationLinks.set(link, details); + } + + await this.sendStartMonetization(validLinks.map((e) => e.details)); + } + + private onWindowMessage = (event: MessageEvent) => { + const { message, id, payload } = event.data; + + if (event.origin === window.location.href || id !== this.id) return; + + switch (message) { + case 'START_MONETIZATION': + return void this.message.send('START_MONETIZATION', payload); + case 'RESUME_MONETIZATION': + return void this.message.send('RESUME_MONETIZATION', payload); + default: + return; + } + }; + + private getMonetizationLinkTags(): HTMLLinkElement[] { + if (this.isTopFrame) { + return Array.from( + this.document.querySelectorAll( + 'link[rel="monetization"]', + ), + ); + } else { + const monetizationTag = this.document.querySelector( + 'head link[rel="monetization"]', + ); + return monetizationTag ? [monetizationTag] : []; + } + } + + /** @throws never throws */ + private async checkLink(link: HTMLLinkElement) { + if (!(link instanceof HTMLLinkElement && link.rel === 'monetization')) { + return null; + } + if (link.hasAttribute('disabled')) { + return null; + } + + const walletAddress = await this.validateLink(link); + if (!walletAddress) { + return null; + } + + return { + link, + details: { + requestId: crypto.randomUUID(), + walletAddress: walletAddress, + }, + }; + } + + /** @throws never throws */ + private async validateLink( + link: HTMLLinkElement, + ): Promise { + const walletAddressUrl = link.href.trim(); + try { + this.checkHrefFormat(walletAddressUrl); + const response = await this.message.send('GET_WALLET_ADDRESS_INFO', { + walletAddressUrl, + }); + + if (response.success === false) { + throw new Error( + `Could not retrieve wallet address information for ${JSON.stringify(walletAddressUrl)}.`, + ); + } + + this.dispatchLoadEvent(link); + return response.payload; + } catch (e) { + this.logger.error(e); + this.dispatchErrorEvent(link); + return null; + } + } + + private checkHrefFormat(href: string): void { + let url: URL; + try { + url = new URL(href); + if (url.protocol !== 'https:') { + throw new WalletAddressFormatError( + `Wallet address URL must be specified as a fully resolved https:// url, ` + + `got ${JSON.stringify(href)} `, + ); + } + } catch (e) { + if (e instanceof WalletAddressFormatError) { + throw e; + } + throw new WalletAddressFormatError( + `Invalid wallet address URL: ${JSON.stringify(href)}`, + ); + } + + const { hash, search, port, username, password } = url; + + if (hash || search || port || username || password) { + throw new WalletAddressFormatError( + `Wallet address URL must not contain query/fragment/port/username/password elements. Received: ${JSON.stringify({ hash, search, port, username, password })}`, + ); + } + } + + private observeLinkAttrs(link: HTMLLinkElement) { + this.monetizationLinkAttrObserver.observe(link, { + childList: false, + attributeOldValue: true, + attributeFilter: ['href', 'disabled', 'rel', 'crossorigin', 'type'], + }); + } + + private dispatchLoadEvent(tag: HTMLLinkElement) { + tag.dispatchEvent(new Event('load')); + } + + private dispatchErrorEvent(tag: HTMLLinkElement) { + tag.dispatchEvent(new Event('error')); + } + + public dispatchMonetizationEvent({ + requestId, + details, + }: MonetizationEventPayload) { + for (const [tag, tagDetails] of this.monetizationLinks) { + if (tagDetails.requestId !== requestId) continue; + + tag.dispatchEvent( + new CustomEvent('__wm_ext_monetization', { + detail: mozClone(details, this.document), + bubbles: true, + }), + ); + break; + } + } + + private dispatchOnMonetizationAttrChangedEvent( + node: HTMLElement, + { changeDetected = false } = {}, + ) { + const attribute = node.getAttribute('onmonetization'); + if (!attribute && !changeDetected) return; + + const customEvent = new CustomEvent('__wm_ext_onmonetization_attr_change', { + bubbles: true, + detail: mozClone({ attribute }, this.document), + }); + node.dispatchEvent(customEvent); + } + + private async stopMonetization() { + const payload: StopMonetizationPayload = [ + ...this.monetizationLinks.values(), + ].map(({ requestId }) => ({ requestId })); + + await this.sendStopMonetization(payload); + } + + private async resumeMonetization() { + const payload: ResumeMonetizationPayload = [ + ...this.monetizationLinks.values(), + ].map(({ requestId }) => ({ requestId })); + + await this.sendResumeMonetization(payload); + } + + private async sendStartMonetization( + payload: StartMonetizationPayload, + onlyToTopIframe = false, + ) { + if (!payload.length) return; + + if (this.isTopFrame) { + await this.message.send('START_MONETIZATION', payload); + } else if (this.isFirstLevelFrame && !onlyToTopIframe) { + this.postMessage('IS_MONETIZATION_ALLOWED_ON_START', payload); + } + } + + private async sendStopMonetization(payload: StopMonetizationPayload) { + if (!payload.length) return; + await this.message.send('STOP_MONETIZATION', payload); + } + + private async sendResumeMonetization( + payload: ResumeMonetizationPayload, + onlyToTopIframe = false, + ) { + if (!payload.length) return; + + if (this.isTopFrame) { + await this.message.send('RESUME_MONETIZATION', payload); + } else if (this.isFirstLevelFrame && !onlyToTopIframe) { + this.postMessage('IS_MONETIZATION_ALLOWED_ON_RESUME', payload); + } + } + + private onDocumentVisibilityChange = async () => { + if (this.document.visibilityState === 'visible') { + await this.resumeMonetization(); + } else { + await this.stopMonetization(); + } + }; + + private onFocus = async () => { + if (this.document.hasFocus()) { + await this.message.send('TAB_FOCUSED'); + } + }; + + private async onWholeDocumentObserved(records: MutationRecord[]) { + const stopMonetizationPayload: StopMonetizationPayload = []; + + for (const record of records) { + if (record.type === 'childList') { + record.removedNodes.forEach((node) => { + if (!(node instanceof HTMLLinkElement)) return; + if (!this.monetizationLinks.has(node)) return; + const payloadEntry = this.onRemovedLink(node); + stopMonetizationPayload.push(payloadEntry); + }); + } + } + + await this.sendStopMonetization(stopMonetizationPayload); + + if (this.isTopFrame) { + const addedNodes = records + .filter((e) => e.type === 'childList') + .flatMap((e) => [...e.addedNodes]); + const allAddedLinkTags = await Promise.all( + addedNodes.map((node) => this.onAddedNode(node)), + ); + const startMonetizationPayload = allAddedLinkTags + .filter(isNotNull) + .map(({ details }) => details); + + void this.sendStartMonetization(startMonetizationPayload); + } + + for (const record of records) { + if ( + record.type === 'attributes' && + record.target instanceof HTMLElement && + record.attributeName === 'onmonetization' + ) { + this.dispatchOnMonetizationAttrChangedEvent(record.target, { + changeDetected: true, + }); + } + } + } + + private postMessage( + message: K, + payload: Extract['payload'], + ) { + this.window.parent.postMessage({ message, id: this.id, payload }, '*'); + } + + private async onLinkAttrChange(records: MutationRecord[]) { + const handledTags = new Set(); + const startMonetizationPayload: StartMonetizationPayload = []; + const stopMonetizationPayload: StopMonetizationPayload = []; + + // Check for a non specified link with the type now specified and + // just treat it as a newly seen, monetization tag + for (const record of records) { + const target = record.target as HTMLLinkElement; + if (handledTags.has(target)) { + continue; + } + + const hasTarget = this.monetizationLinks.has(target); + const linkRelSpecified = + target instanceof HTMLLinkElement && target.rel === 'monetization'; + // this will also handle the case of a @disabled tag that + // is not tracked, becoming enabled + if (!hasTarget && linkRelSpecified) { + const payloadEntry = await this.checkLink(target); + if (payloadEntry) { + this.monetizationLinks.set(target, payloadEntry.details); + startMonetizationPayload.push(payloadEntry.details); + } + handledTags.add(target); + } else if (hasTarget && !linkRelSpecified) { + const payloadEntry = this.onRemovedLink(target); + stopMonetizationPayload.push(payloadEntry); + handledTags.add(target); + } else if (!hasTarget && !linkRelSpecified) { + // ignore these changes + handledTags.add(target); + } else if (hasTarget && linkRelSpecified) { + if ( + record.type === 'attributes' && + record.attributeName === 'disabled' && + target instanceof HTMLLinkElement && + target.getAttribute('disabled') !== record.oldValue + ) { + const wasDisabled = record.oldValue !== null; + const isDisabled = target.hasAttribute('disabled'); + if (wasDisabled != isDisabled) { + try { + const details = this.monetizationLinks.get(target); + if (!details) { + throw new Error('Could not find details for monetization node'); + } + if (isDisabled) { + stopMonetizationPayload.push({ + requestId: details.requestId, + intent: 'disable', + }); + } else { + startMonetizationPayload.push(details); + } + } catch { + const payloadEntry = await this.checkLink(target); + if (payloadEntry) { + this.monetizationLinks.set(target, payloadEntry.details); + startMonetizationPayload.push(payloadEntry.details); + } + } + + handledTags.add(target); + } + } else if ( + record.type === 'attributes' && + record.attributeName === 'href' && + target instanceof HTMLLinkElement && + target.href !== record.oldValue + ) { + stopMonetizationPayload.push(this.onRemovedLink(target)); + const payloadEntry = await this.checkLink(target); + if (payloadEntry) { + startMonetizationPayload.push(payloadEntry.details); + } + handledTags.add(target); + } + } + } + + await this.sendStopMonetization(stopMonetizationPayload); + void this.sendStartMonetization(startMonetizationPayload); + } + + private async onAddedNode(node: Node) { + if (node instanceof HTMLElement) { + this.dispatchOnMonetizationAttrChangedEvent(node); + } + + if (node instanceof HTMLLinkElement) { + return await this.onAddedLink(node); + } + return null; + } + + private async onAddedLink(link: HTMLLinkElement) { + this.observeLinkAttrs(link); + const res = await this.checkLink(link); + if (res) { + this.monetizationLinks.set(link, res.details); + } + return res; + } + + private onRemovedLink(link: HTMLLinkElement): StopMonetizationPayloadEntry { + const details = this.monetizationLinks.get(link); + if (!details) { + throw new Error( + 'Could not find details for monetization node ' + + // node is removed, so the reference can not be displayed + link.outerHTML.slice(0, 200), + ); + } + + this.monetizationLinks.delete(link); + + return { requestId: details.requestId, intent: 'remove' }; + } +} diff --git a/src/content/services/monetizationTagManager.ts b/src/content/services/monetizationTagManager.ts deleted file mode 100644 index 084e4bdd..00000000 --- a/src/content/services/monetizationTagManager.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { EventEmitter } from 'events' -import { mozClone } from '../utils' -import type { MonetizationTagDetails } from '../types' -import type { WalletAddress } from '@interledger/open-payments/dist/types' -import { checkWalletAddressUrlFormat } from '../utils' -import { - checkWalletAddressUrlCall, - isWMEnabled, - resumeMonetization, - startMonetization, - stopMonetization -} from '../lib/messages' -import type { - EmitToggleWMPayload, - MonetizationEventPayload, - ResumeMonetizationPayload, - StartMonetizationPayload, - StopMonetizationPayload -} from '@/shared/messages' -import { ContentToContentAction } from '../messages' -import type { Cradle } from '@/content/container' - -export type MonetizationTag = HTMLLinkElement - -interface FireOnMonetizationChangeIfHaveAttributeParams { - node: HTMLElement - changeDetected?: boolean -} - -export class MonetizationTagManager extends EventEmitter { - private window: Cradle['window'] - private document: Cradle['document'] - private logger: Cradle['logger'] - - private isTopFrame: boolean - private isFirstLevelFrame: boolean - private documentObserver: MutationObserver - private monetizationTagAttrObserver: MutationObserver - private id: string - private monetizationTags = new Map() - - constructor({ window, document, logger }: Cradle) { - super() - Object.assign(this, { - window, - document, - logger - }) - - this.documentObserver = new MutationObserver((records) => - this.onWholeDocumentObserved(records) - ) - this.monetizationTagAttrObserver = new MutationObserver((records) => - this.onMonetizationTagAttrsChange(records) - ) - - document.addEventListener('visibilitychange', async () => { - if (document.visibilityState === 'visible') { - await this.resumeAllMonetization() - } else { - this.stopAllMonetization() - } - }) - - this.isTopFrame = window === window.top - this.isFirstLevelFrame = window.parent === window.top - this.id = crypto.randomUUID() - - if (!this.isTopFrame && this.isFirstLevelFrame) { - this.bindMessageHandler() - } - } - - private dispatchLoadEvent(tag: MonetizationTag) { - tag.dispatchEvent(new Event('load')) - } - - private dispatchErrorEvent(tag: MonetizationTag) { - tag.dispatchEvent(new Event('error')) - } - - dispatchMonetizationEvent({ requestId, details }: MonetizationEventPayload) { - this.monetizationTags.forEach((tagDetails, tag) => { - if (tagDetails.requestId !== requestId) return - - tag.dispatchEvent( - new CustomEvent('__wm_ext_monetization', { - detail: mozClone(details, this.document), - bubbles: true - }) - ) - }) - return - } - - private async resumeAllMonetization() { - const response = await isWMEnabled() - - if (response.success && response.payload) { - const resumeMonetizationTags: ResumeMonetizationPayload[] = [] - - this.monetizationTags.forEach((value) => { - if (value.requestId && value.walletAddress) { - resumeMonetizationTags.push({ requestId: value.requestId }) - } - }) - - this.sendResumeMonetization(resumeMonetizationTags) - } - } - - private stopAllMonetization(intent?: StopMonetizationPayload['intent']) { - const stopMonetizationTags: StopMonetizationPayload[] = [] - this.monetizationTags.forEach((value) => { - if (value.requestId && value.walletAddress) { - stopMonetizationTags.push({ requestId: value.requestId, intent }) - } - }) - - this.sendStopMonetization(stopMonetizationTags) - } - - private async onWholeDocumentObserved(records: MutationRecord[]) { - const startMonetizationTagsPromises: Promise[] = - [] - const stopMonetizationTags: StopMonetizationPayload[] = [] - - for (const record of records) { - if (record.type === 'childList') { - record.removedNodes.forEach(async (node) => { - const stopMonetizationTag = this.checkRemoved(node) - if (stopMonetizationTag) - stopMonetizationTags.push(stopMonetizationTag) - }) - } - } - - await this.sendStopMonetization(stopMonetizationTags) - - if (this.isTopFrame) { - for (const record of records) { - if (record.type === 'childList') { - record.addedNodes.forEach(async (node) => { - const startMonetizationTag = this.checkAdded(node) - startMonetizationTagsPromises.push(startMonetizationTag) - }) - } - } - - Promise.allSettled(startMonetizationTagsPromises).then((result) => { - const startMonetizationTags: StartMonetizationPayload[] = [] - result.forEach((res) => { - if (res.status === 'fulfilled' && res.value) { - startMonetizationTags.push(res.value) - } - }) - - this.sendStartMonetization(startMonetizationTags) - }) - } - - this.onOnMonetizationChangeObserved(records) - } - - async onMonetizationTagAttrsChange(records: MutationRecord[]) { - const handledTags = new Set() - const startMonetizationTags: StartMonetizationPayload[] = [] - const stopMonetizationTags: StopMonetizationPayload[] = [] - - // Check for a non specified link with the type now specified and - // just treat it as a newly seen, monetization tag - for (const record of records) { - const target = record.target as MonetizationTag - if (handledTags.has(target)) { - continue - } - const hasTarget = this.monetizationTags.has(target) - const typeSpecified = - target instanceof HTMLLinkElement && target.rel === 'monetization' - // this will also handle the case of a @disabled tag that - // is not tracked, becoming enabled - if (!hasTarget && typeSpecified) { - const startMonetizationTag = await this.onAddedTag(target) - if (startMonetizationTag) - startMonetizationTags.push(startMonetizationTag) - - handledTags.add(target) - } else if (hasTarget && !typeSpecified) { - const stopMonetizationTag = this.onRemovedTag(target) - stopMonetizationTags.push(stopMonetizationTag) - - handledTags.add(target) - } else if (!hasTarget && !typeSpecified) { - // ignore these changes - handledTags.add(target) - } else if (hasTarget && typeSpecified) { - if ( - record.type === 'attributes' && - record.attributeName === 'disabled' && - target instanceof HTMLLinkElement && - target.getAttribute('disabled') !== record.oldValue - ) { - const wasDisabled = record.oldValue !== null - const isDisabled = target.hasAttribute('disabled') - if (wasDisabled != isDisabled) { - try { - const { requestId, walletAddress } = this.getTagDetails( - target, - 'onChangeDisabled' - ) - if (isDisabled) { - stopMonetizationTags.push({ requestId, intent: 'disable' }) - } else if (walletAddress) { - startMonetizationTags.push({ requestId, walletAddress }) - } - } catch { - const startMonetizationPayload = await this.onAddedTag(target) - if (startMonetizationPayload) { - startMonetizationTags.push(startMonetizationPayload) - } - } - - handledTags.add(target) - } - } else if ( - record.type === 'attributes' && - record.attributeName === 'href' && - target instanceof HTMLLinkElement && - target.href !== record.oldValue - ) { - const { startMonetizationTag, stopMonetizationTag } = - await this.onChangedWalletAddressUrl(target) - if (startMonetizationTag) - startMonetizationTags.push(startMonetizationTag) - if (stopMonetizationTag) - stopMonetizationTags.push(stopMonetizationTag) - - handledTags.add(target) - } - } - } - - await this.sendStopMonetization(stopMonetizationTags) - this.sendStartMonetization(startMonetizationTags) - } - - private async checkAdded(node: Node) { - if (node instanceof HTMLElement) { - this.fireOnMonetizationAttrChangedEvent({ node }) - } - - if (node instanceof HTMLLinkElement) { - this.observeMonetizationTagAttrs(node) - return await this.onAddedTag(node) - } - - return null - } - - private checkRemoved(node: Node) { - return node instanceof HTMLLinkElement && this.monetizationTags.has(node) - ? this.onRemovedTag(node) - : null - } - - private observeMonetizationTagAttrs(tag: MonetizationTag) { - this.monetizationTagAttrObserver.observe(tag, { - childList: false, - attributeOldValue: true, - attributeFilter: ['href', 'disabled', 'rel', 'crossorigin', 'type'] - }) - } - - private getTagDetails(tag: MonetizationTag, caller = '') { - const tagDetails = this.monetizationTags.get(tag) - - if (!tagDetails) { - throw new Error( - `${caller}: tag not tracked: ${tag.outerHTML.slice(0, 200)}` - ) - } - - return tagDetails - } - - // If wallet address changed, remove old tag and add new one - async onChangedWalletAddressUrl( - tag: MonetizationTag, - wasDisabled = false, - isDisabled = false - ) { - let stopMonetizationTag = null - - if (!wasDisabled && !isDisabled) { - stopMonetizationTag = this.onRemovedTag(tag) - } - - const startMonetizationTag = await this.onAddedTag(tag) - - return { startMonetizationTag, stopMonetizationTag } - } - - private onOnMonetizationChangeObserved(records: MutationRecord[]) { - for (const record of records) { - if ( - record.type === 'attributes' && - record.target instanceof HTMLElement && - record.attributeName === 'onmonetization' - ) { - this.fireOnMonetizationAttrChangedEvent({ - node: record.target, - changeDetected: true - }) - } - } - } - - private fireOnMonetizationAttrChangedEvent({ - node, - changeDetected = false - }: FireOnMonetizationChangeIfHaveAttributeParams) { - const attribute = node.getAttribute('onmonetization') - - if (!attribute && !changeDetected) return - - const customEvent = new CustomEvent('__wm_ext_onmonetization_attr_change', { - bubbles: true, - detail: mozClone({ attribute }, this.document) - }) - - node.dispatchEvent(customEvent) - } - - private isDocumentReady() { - return ( - (document.readyState === 'interactive' || - document.readyState === 'complete') && - document.visibilityState === 'visible' - ) - } - - start(): void { - if (this.isDocumentReady()) { - this.run() - return - } - - document.addEventListener( - 'readystatechange', - () => { - if (this.isDocumentReady()) { - this.run() - } else { - document.addEventListener( - 'visibilitychange', - () => { - if (this.isDocumentReady()) { - this.run() - } - }, - { once: true } - ) - } - }, - { once: true } - ) - } - - private run() { - if (!this.isTopFrame && this.isFirstLevelFrame) { - this.window.parent.postMessage( - { - message: ContentToContentAction.INITIALIZE_IFRAME, - id: this.id - }, - '*' - ) - } - - let monetizationTags: NodeListOf | MonetizationTag[] - - if (this.isTopFrame) { - monetizationTags = this.document.querySelectorAll( - 'link[rel="monetization"]' - ) - } else { - const monetizationTag: MonetizationTag | null = - this.document.querySelector('head link[rel="monetization"]') - monetizationTags = monetizationTag ? [monetizationTag] : [] - } - - const startMonetizationTagsPromises: Promise[] = - [] - - monetizationTags.forEach(async (tag) => { - try { - this.observeMonetizationTagAttrs(tag) - const startMonetizationTag = this.onAddedTag(tag) - startMonetizationTagsPromises.push(startMonetizationTag) - } catch (e) { - this.logger.error(e) - } - }) - - Promise.allSettled(startMonetizationTagsPromises).then((result) => { - const startMonetizationTags: StartMonetizationPayload[] = [] - result.forEach((res) => { - if (res.status === 'fulfilled' && res.value) { - startMonetizationTags.push(res.value) - } - }) - - this.sendStartMonetization(startMonetizationTags) - }) - - const onMonetizations: NodeListOf = - this.document.querySelectorAll('[onmonetization]') - - onMonetizations.forEach((node) => { - this.fireOnMonetizationAttrChangedEvent({ node }) - }) - - this.documentObserver.observe(this.document, { - subtree: true, - childList: true, - attributeFilter: ['onmonetization'] - }) - } - - stop() { - this.documentObserver.disconnect() - this.monetizationTagAttrObserver.disconnect() - this.monetizationTags.clear() - } - - // Remove tag from list & stop monetization - private onRemovedTag(tag: MonetizationTag): StopMonetizationPayload { - const { requestId } = this.getTagDetails(tag, 'onRemovedTag') - this.monetizationTags.delete(tag) - - return { requestId, intent: 'remove' } - } - - // Add tag to list & start monetization - private async onAddedTag( - tag: MonetizationTag, - crtRequestId?: string - ): Promise { - const walletAddress = await this.checkTag(tag) - if (!walletAddress) return null - - const requestId = crtRequestId ?? crypto.randomUUID() - const details: MonetizationTagDetails = { - walletAddress, - requestId - } - - this.monetizationTags.set(tag, details) - return { walletAddress, requestId } - } - - private sendStartMonetization(tags: StartMonetizationPayload[]) { - if (!tags.length) return - - if (this.isTopFrame) { - startMonetization(tags) - } else if (this.isFirstLevelFrame) { - this.window.parent.postMessage( - { - message: ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_START, - id: this.id, - payload: tags - }, - '*' - ) - } - } - - private async sendStopMonetization(tags: StopMonetizationPayload[]) { - if (!tags.length) return - await stopMonetization(tags) - } - - private sendResumeMonetization(tags: ResumeMonetizationPayload[]) { - if (this.isTopFrame) { - resumeMonetization(tags) - } else if (this.isFirstLevelFrame) { - this.window.parent.postMessage( - { - message: ContentToContentAction.IS_MONETIZATION_ALLOWED_ON_RESUME, - id: this.id, - payload: tags - }, - '*' - ) - } - } - - // Check tag to be enabled and for valid wallet address - private async checkTag(tag: MonetizationTag): Promise { - if (!(tag instanceof HTMLLinkElement && tag.rel === 'monetization')) - return null - - if (tag.hasAttribute('disabled')) return null - - const walletAddressInfo = await this.validateWalletAddress(tag) - - return walletAddressInfo - } - - private async validateWalletAddress( - tag: MonetizationTag - ): Promise { - const walletAddressUrl = tag.href.trim() - try { - checkWalletAddressUrlFormat(walletAddressUrl) - const response = await checkWalletAddressUrlCall({ walletAddressUrl }) - - if (response.success === false) { - throw new Error( - `Could not retrieve wallet address information for ${JSON.stringify(walletAddressUrl)}.` - ) - } - - this.dispatchLoadEvent(tag) - return response.payload - } catch (e) { - this.logger.error(e) - this.dispatchErrorEvent(tag) - return null - } - } - - private bindMessageHandler() { - this.window.addEventListener('message', (event) => { - const { message, id, payload } = event.data - - if (event.origin === window.location.href || id !== this.id) return - - switch (message) { - case ContentToContentAction.START_MONETIZATION: - startMonetization(payload) - return - case ContentToContentAction.RESUME_MONETIZATION: - resumeMonetization(payload) - return - default: - return - } - }) - } - - async toggleWM({ enabled }: EmitToggleWMPayload) { - if (enabled) { - await this.resumeAllMonetization() - } else { - // TODO: https://github.com/interledger/web-monetization-extension/issues/452 - this.stopAllMonetization() - } - } -} diff --git a/src/content/types.ts b/src/content/types.ts deleted file mode 100644 index ec773fc7..00000000 --- a/src/content/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { WalletAddress } from '@interledger/open-payments/dist/types' - -export type MonetizationTag = HTMLLinkElement & { href?: string } -export type MonetizationTagList = NodeListOf - -export type MonetizationTagDetails = { - walletAddress: WalletAddress | null - requestId: string -} diff --git a/src/content/utils.ts b/src/content/utils.ts index 0b59fc81..2899b74d 100644 --- a/src/content/utils.ts +++ b/src/content/utils.ts @@ -1,62 +1,33 @@ export class WalletAddressFormatError extends Error {} -export function checkWalletAddressUrlFormat(walletAddressUrl: string): void { - let url: URL - try { - url = new URL(walletAddressUrl) - if (url.protocol !== 'https:') { - throw new WalletAddressFormatError( - `Wallet address URL must be specified as a fully resolved https:// url, ` + - `got ${JSON.stringify(walletAddressUrl)} ` - ) - } - } catch (e) { - if (e instanceof WalletAddressFormatError) { - throw e - } else { - throw new WalletAddressFormatError( - `Invalid wallet address URL: ${JSON.stringify(walletAddressUrl)}` - ) - } - } - - const { hash, search, port, username, password } = url - - if (hash || search || port || username || password) { - throw new WalletAddressFormatError( - `Wallet address URL must not contain query/fragment/port/username/password elements. Received: ${JSON.stringify({ hash, search, port, username, password })}` - ) - } -} - -type DefaultView = WindowProxy & typeof globalThis -type CloneInto = (obj: unknown, _window: DefaultView | null) => typeof obj -declare const cloneInto: CloneInto | undefined +type DefaultView = WindowProxy & typeof globalThis; +type CloneInto = (obj: unknown, _window: DefaultView | null) => typeof obj; +declare const cloneInto: CloneInto | undefined; -let cloneIntoRef: CloneInto | undefined +let cloneIntoRef: CloneInto | undefined; try { - cloneIntoRef = cloneInto + cloneIntoRef = cloneInto; } catch { - cloneIntoRef = undefined + cloneIntoRef = undefined; } export function mozClone(obj: T, document: Document) { - return cloneIntoRef ? cloneIntoRef(obj, document.defaultView) : obj + return cloneIntoRef ? cloneIntoRef(obj, document.defaultView) : obj; } export class CustomError extends Error { constructor(message?: string) { // 'Error' breaks prototype chain here - super(message) + super(message); // restore prototype chain - const actualProto = new.target.prototype + const actualProto = new.target.prototype; if (Object.setPrototypeOf) { - Object.setPrototypeOf(this, actualProto) + Object.setPrototypeOf(this, actualProto); } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(this as any).__proto__ = actualProto + (this as any).__proto__ = actualProto; } } } diff --git a/src/manifest.json b/src/manifest.json index d8b29928..a4d06797 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -17,19 +17,24 @@ "js": ["content/content.js"], "run_at": "document_start", "all_frames": true + }, + { + "matches": ["https://rafiki.money/*/*"], + "js": ["content/keyAutoAdd/testWallet.js"], + "run_at": "document_end" } ], "background": { "service_worker": "background/background.js" }, - "permissions": ["tabs", "storage", "alarms"], + "permissions": ["tabs", "storage", "alarms", "scripting"], "action": { "default_title": "Web Monetization", "default_popup": "popup/index.html" }, "web_accessible_resources": [ { - "resources": ["assets/*", "polyfill/*"], + "resources": ["polyfill/*", "pages/progress-connect/*"], "matches": [""] } ], diff --git a/src/pages/progress-connect/App.tsx b/src/pages/progress-connect/App.tsx new file mode 100644 index 00000000..5e583116 --- /dev/null +++ b/src/pages/progress-connect/App.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useUIMode } from '@/pages/progress-connect/context'; +import { AppNotification } from '@/pages/progress-connect/components/AppNotification'; +import { AppFullscreen } from '@/pages/progress-connect/components/AppFullScreen'; + +export default function App() { + const mode = useUIMode(); + + React.useEffect(() => { + const container = document.getElementById('container')!; + container.style.height = mode === 'fullscreen' ? '100vh' : 'auto'; + document.body.style.backgroundColor = + mode === 'fullscreen' ? 'rgba(255, 255, 255, 0.95)' : 'white'; + }, [mode]); + + if (mode === 'fullscreen') { + return ; + } else { + return ; + } +} diff --git a/src/pages/progress-connect/components/AppFullScreen.tsx b/src/pages/progress-connect/components/AppFullScreen.tsx new file mode 100644 index 00000000..feae7825 --- /dev/null +++ b/src/pages/progress-connect/components/AppFullScreen.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { useState } from '@/pages/progress-connect/context'; +import { useBrowser } from '@/popup/lib/context'; +import { Steps } from './Steps'; + +export function AppFullscreen() { + const browser = useBrowser(); + const state = useState(); + + const Logo = browser.runtime.getURL('assets/images/logo.svg'); + + return ( +
+
+ +

Web Monetization

+

Connecting wallet…

+
+
+ +

{state.currentStep.name}…

+
+
+ ); +} diff --git a/src/pages/progress-connect/components/AppNotification.tsx b/src/pages/progress-connect/components/AppNotification.tsx new file mode 100644 index 00000000..6ea622cf --- /dev/null +++ b/src/pages/progress-connect/components/AppNotification.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { HeaderEmpty } from '@/popup/components/layout/HeaderEmpty'; +import { LoadingSpinner } from '@/popup/components/LoadingSpinner'; +import { Countdown } from '@/pages/progress-connect/components/Countdown'; +import { useState } from '@/pages/progress-connect/context'; +import { useBrowser } from '@/popup/lib/context'; + +export function AppNotification() { + const browser = useBrowser(); + const { currentStep } = useState(); + + const logo = browser.runtime.getURL('assets/images/logo.svg'); + + return ( +
+ +
+
+ +

+ {currentStep.name}… + {currentStep.status === 'active' && currentStep.expiresAt && ( + + )} +

+
+
+ ); +} diff --git a/src/pages/progress-connect/components/Countdown.tsx b/src/pages/progress-connect/components/Countdown.tsx new file mode 100644 index 00000000..f3446084 --- /dev/null +++ b/src/pages/progress-connect/components/Countdown.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { cn } from '@/shared/helpers'; + +export function Countdown({ + expiresAt, + className, +}: { + expiresAt: number; + className?: string; +}) { + const timer = useCountdown(expiresAt); + + return ( + + {timer[0]}:{timer[1]} + + ); +} + +export function useCountdown(expiresAt: number) { + const getMinuteAndSecond = (deadline: number) => { + const distance = deadline - Date.now(); + if (distance < 0) { + return ['00', '00'] as const; + } + const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((distance % (1000 * 60)) / 1000); + return [ + minutes.toString().padStart(2, '0'), + seconds.toString().padStart(2, '0'), + ] as const; + }; + + const [value, setValue] = React.useState(getMinuteAndSecond(expiresAt)); + + React.useEffect(() => { + let requestId: ReturnType; + + const tick = () => { + const val = getMinuteAndSecond(expiresAt); + setValue(val); + requestId = requestAnimationFrame(tick); + if (val[0] === '00' && val[1] === '00') { + cancelAnimationFrame(requestId); + } + }; + + requestId = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(requestId); + }; + }, [expiresAt]); + + return value; +} diff --git a/src/pages/progress-connect/components/Steps.tsx b/src/pages/progress-connect/components/Steps.tsx new file mode 100644 index 00000000..b380c71d --- /dev/null +++ b/src/pages/progress-connect/components/Steps.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { cn } from '@/shared/helpers'; +import type { StepWithStatus } from '@/content/keyAutoAdd/lib/types'; + +export function Steps({ steps }: { steps: StepWithStatus[] }) { + return ( +
+ {steps.map((step) => ( + + ))} +
+ ); +} + +function Step({ step }: { step: StepWithStatus }) { + return ( +
+ ); +} diff --git a/src/pages/progress-connect/context.tsx b/src/pages/progress-connect/context.tsx new file mode 100644 index 00000000..d8484ad0 --- /dev/null +++ b/src/pages/progress-connect/context.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import type { Runtime } from 'webextension-polyfill'; +import { useBrowser } from '@/popup/lib/context'; +import type { + KeyAutoAddToBackgroundMessage, + StepWithStatus, +} from '@/content/keyAutoAdd/lib/types'; +import { CONNECTION_NAME } from '@/background/services/keyAutoAdd'; + +type State = { + currentStep: StepWithStatus; + steps: StepWithStatus[]; +}; + +type OnPortMessageListener = Parameters< + Runtime.Port['onMessage']['addListener'] +>[0]; + +const StateContext = React.createContext({ + currentStep: { name: '', status: 'active' }, + steps: [], +}); + +export const StateContextProvider = ({ children }: React.PropsWithChildren) => { + const browser = useBrowser(); + const [state, setState] = React.useState({ + currentStep: { name: '', status: 'active' }, + steps: [], + }); + + React.useEffect(() => { + const onMessage: OnPortMessageListener = ( + message: KeyAutoAddToBackgroundMessage, + ) => { + if (message.action === 'PROGRESS') { + const { steps } = message.payload; + const currentStep = getCurrentStep(steps); + setState({ + currentStep: currentStep || { name: '', status: 'pending' }, + steps: steps, + }); + } + }; + + const port = browser.runtime.connect({ name: CONNECTION_NAME }); + port.onMessage.addListener(onMessage); + return () => { + port.disconnect(); + }; + }, [browser]); + + return ( + {children} + ); +}; + +function getCurrentStep(steps: Readonly) { + return steps + .slice() + .reverse() + .find((step) => step.status !== 'pending'); +} + +export const useState = () => React.useContext(StateContext); + +type UIMode = 'notification' | 'fullscreen'; +const UIModeContext = React.createContext('notification'); +export const UIModeProvider = ({ children }: React.PropsWithChildren) => { + const [mode, setMode] = React.useState('notification'); + + React.useEffect(() => { + const onHashChange = () => { + const params = new URLSearchParams(window.location.hash.slice(1)); + const mode = params.get('mode'); + if (mode === 'fullscreen' || mode === 'notification') { + setMode(mode); + } + }; + onHashChange(); + window.addEventListener('hashchange', onHashChange); + return () => { + window.removeEventListener('hashchange', onHashChange); + }; + }, []); + + return ( + {children} + ); +}; + +export const useUIMode = () => React.useContext(UIModeContext); diff --git a/src/pages/progress-connect/index.css b/src/pages/progress-connect/index.css new file mode 100644 index 00000000..4c8f633b --- /dev/null +++ b/src/pages/progress-connect/index.css @@ -0,0 +1,39 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* Text colors */ + --text-primary: 59 130 246; + --text-weak: 100 116 139; + --text-medium: 51 65 85; + --text-strong: 15 23 42; + --text-error: 239 68 68; + --text-disabled: 148 163 184; + + /* Background colors */ + --bg-primary: 59 130 246; + --bg-nav-active: 226 232 240; + --bg-error: 254 226 226; + --bg-error-hover: 254 202 202; + --bg-button-base: 86 183 181; + --bg-button-base-hover: 52 152 152; + --bg-switch-base: 152 225 208; + --bg-disabled: 248 250 252; + --bg-disabled-strong: 203 213 225; + + /* Border colors */ + --border-base: 203 213 225; + --border-focus: 59 130 246; + --border-error: 220 38 38; + + /* Popup */ + --popup-width: 448px; + --popup-height: 600px; + } + + body { + @apply bg-white text-base text-medium; + } +} diff --git a/src/pages/progress-connect/index.html b/src/pages/progress-connect/index.html new file mode 100644 index 00000000..3f8df81e --- /dev/null +++ b/src/pages/progress-connect/index.html @@ -0,0 +1,13 @@ + + + + + Web Monetization Extension - Connecting… + + + + + +
+ + diff --git a/src/pages/progress-connect/index.tsx b/src/pages/progress-connect/index.tsx new file mode 100755 index 00000000..dbed1cab --- /dev/null +++ b/src/pages/progress-connect/index.tsx @@ -0,0 +1,25 @@ +import './index.css'; + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import browser from 'webextension-polyfill'; + +import { + BrowserContextProvider, + TranslationContextProvider, +} from '@/popup/lib/context'; +import { StateContextProvider, UIModeProvider } from './context'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('container')!); +root.render( + + + + + + + + + , +); diff --git a/src/popup/Popup.tsx b/src/popup/Popup.tsx index 19897e9d..42ab49ab 100644 --- a/src/popup/Popup.tsx +++ b/src/popup/Popup.tsx @@ -1,18 +1,19 @@ -import { MainLayout } from '@/popup/components/layout/MainLayout' +import { MainLayout } from '@/popup/components/layout/MainLayout'; import { BrowserContextProvider, + MessageContextProvider, PopupContextProvider, - TranslationContextProvider -} from './lib/context' -import { LazyMotion, domAnimation } from 'framer-motion' -import React from 'react' -import browser from 'webextension-polyfill' -import { ProtectedRoute } from '@/popup/components/ProtectedRoute' + TranslationContextProvider, +} from './lib/context'; +import { LazyMotion, domAnimation } from 'framer-motion'; +import React from 'react'; +import browser from 'webextension-polyfill'; +import { ProtectedRoute } from '@/popup/components/ProtectedRoute'; import { RouteObject, RouterProvider, - createMemoryRouter -} from 'react-router-dom' + createMemoryRouter, +} from 'react-router-dom'; export const ROUTES_PATH = { HOME: '/', @@ -20,8 +21,8 @@ export const ROUTES_PATH = { MISSING_HOST_PERMISSION: '/missing-host-permission', OUT_OF_FUNDS: '/out-of-funds', OUT_OF_FUNDS_ADD_FUNDS: '/out-of-funds/s/add-funds', - ERROR_KEY_REVOKED: '/error/key-revoked' -} as const + ERROR_KEY_REVOKED: '/error/key-revoked', +} as const; export const routes = [ { @@ -32,50 +33,52 @@ export const routes = [ children: [ { path: ROUTES_PATH.HOME, - lazy: () => import('./pages/Home') - } - ] + lazy: () => import('./pages/Home'), + }, + ], }, { children: [ { path: ROUTES_PATH.MISSING_HOST_PERMISSION, - lazy: () => import('./pages/MissingHostPermission') + lazy: () => import('./pages/MissingHostPermission'), }, { path: ROUTES_PATH.ERROR_KEY_REVOKED, - lazy: () => import('./pages/ErrorKeyRevoked') + lazy: () => import('./pages/ErrorKeyRevoked'), }, { path: ROUTES_PATH.OUT_OF_FUNDS, - lazy: () => import('./pages/OutOfFunds') + lazy: () => import('./pages/OutOfFunds'), }, { path: ROUTES_PATH.OUT_OF_FUNDS_ADD_FUNDS, - lazy: () => import('./pages/OutOfFunds_AddFunds') + lazy: () => import('./pages/OutOfFunds_AddFunds'), }, { path: ROUTES_PATH.SETTINGS, - lazy: () => import('./pages/Settings') - } - ] - } - ] - } -] satisfies RouteObject[] + lazy: () => import('./pages/Settings'), + }, + ], + }, + ], + }, +] satisfies RouteObject[]; -const router = createMemoryRouter(routes) +const router = createMemoryRouter(routes); export const Popup = () => { return ( - - - - - + + + + + + + - ) -} + ); +}; diff --git a/src/popup/components/AllSessionsInvalid.tsx b/src/popup/components/AllSessionsInvalid.tsx deleted file mode 100644 index 6b172c1d..00000000 --- a/src/popup/components/AllSessionsInvalid.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react' -import { WarningSign } from '@/popup/components/Icons' -import { useTranslation } from '@/popup/lib/context' - -export const AllSessionsInvalid = () => { - const t = useTranslation() - return ( -
-
- -
-

{t('allInvalidLinks_state_text')}

-
- ) -} diff --git a/src/popup/components/ConnectWalletForm.tsx b/src/popup/components/ConnectWalletForm.tsx index c825d0bd..26694dba 100644 --- a/src/popup/components/ConnectWalletForm.tsx +++ b/src/popup/components/ConnectWalletForm.tsx @@ -1,204 +1,580 @@ -import React, { useCallback, useEffect } from 'react' -import { Button } from '@/popup/components/ui/Button' -import { Input } from '@/popup/components/ui/Input' -import { Label } from '@/popup/components/ui/Label' -import { Switch } from '@/popup/components/ui/Switch' -import { Code } from '@/popup/components/ui/Code' -import { connectWallet } from '@/popup/lib/messages' -import { debounceSync, getWalletInformation } from '@/shared/helpers' +import React from 'react'; +import { Button } from '@/popup/components/ui/Button'; +import { Input } from '@/popup/components/ui/Input'; +import { Switch } from '@/popup/components/ui/Switch'; +import { Code } from '@/popup/components/ui/Code'; +import { ErrorMessage } from '@/popup/components/ErrorMessage'; +import { LoadingSpinner } from '@/popup/components/LoadingSpinner'; import { charIsNumber, formatNumber, getCurrencySymbol, - toWalletAddressUrl -} from '@/popup/lib/utils' -import { useForm } from 'react-hook-form' - -interface ConnectWalletFormInputs { - walletAddressUrl: string - amount: string - recurring: boolean + toWalletAddressUrl, +} from '@/popup/lib/utils'; +import { useTranslation } from '@/popup/lib/context'; +import { + cn, + errorWithKey, + isErrorWithKey, + sleep, + type ErrorWithKeyLike, +} from '@/shared/helpers'; +import type { WalletAddress } from '@interledger/open-payments'; +import type { ConnectWalletPayload, Response } from '@/shared/messages'; +import type { PopupTransientState } from '@/shared/types'; + +interface Inputs { + walletAddressUrl: string; + amount: string; + recurring: boolean; + autoKeyAddConsent: boolean; } +type ErrorInfo = { message: string; info?: ErrorWithKeyLike }; +type ErrorsParams = 'walletAddressUrl' | 'amount' | 'keyPair' | 'connect'; +type Errors = Record; + interface ConnectWalletFormProps { - publicKey: string + publicKey: string; + defaultValues: Partial; + state?: PopupTransientState['connect']; + saveValue?: (key: keyof Inputs, val: Inputs[typeof key]) => void; + getWalletInfo: (walletAddressUrl: string) => Promise; + connectWallet: (data: ConnectWalletPayload) => Promise; + clearConnectState: () => Promise; + onConnect?: () => void; } -export const ConnectWalletForm = ({ publicKey }: ConnectWalletFormProps) => { - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - clearErrors, - setError, - setValue - } = useForm({ - criteriaMode: 'firstError', - mode: 'onSubmit', - reValidateMode: 'onBlur', - defaultValues: { - recurring: localStorage?.getItem('recurring') === 'true' || false, - amount: localStorage?.getItem('amountValue') || undefined, - walletAddressUrl: localStorage?.getItem('walletAddressUrl') || undefined - } - }) +export const ConnectWalletForm = ({ + publicKey, + defaultValues, + state, + getWalletInfo, + connectWallet, + clearConnectState, + saveValue = () => {}, + onConnect = () => {}, +}: ConnectWalletFormProps) => { + const t = useTranslation(); + + const [walletAddressUrl, setWalletAddressUrl] = React.useState< + Inputs['walletAddressUrl'] + >(defaultValues.walletAddressUrl || ''); + const [amount, setAmount] = React.useState( + defaultValues.amount || '5.00', + ); + const [recurring, setRecurring] = React.useState( + defaultValues.recurring || false, + ); + + const [autoKeyShareFailed, setAutoKeyShareFailed] = React.useState( + isAutoKeyAddFailed(state), + ); + const [showConsent, setShowConsent] = React.useState(false); + const autoKeyAddConsent = React.useRef( + defaultValues.autoKeyAddConsent || false, + ); + + const resetState = React.useCallback(async () => { + await clearConnectState(); + setErrors((prev) => ({ ...prev, keyPair: null, connect: null })); + setAutoKeyShareFailed(false); + }, [clearConnectState]); + + const toErrorInfo = React.useCallback( + (err?: string | ErrorWithKeyLike | null): ErrorInfo | null => { + if (!err) return null; + if (typeof err === 'string') return { message: err }; + return { message: t(err), info: err }; + }, + [t], + ); + + const [walletAddressInfo, setWalletAddressInfo] = + React.useState(null); + + const [errors, setErrors] = React.useState({ + walletAddressUrl: null, + amount: null, + keyPair: state?.status === 'error:key' ? toErrorInfo(state.error) : null, + connect: state?.status === 'error' ? toErrorInfo(state.error) : null, + }); + const [isValidating, setIsValidating] = React.useState({ + walletAddressUrl: false, + amount: false, + }); + const [isSubmitting, setIsSubmitting] = React.useState( + state?.status?.startsWith('connecting') || false, + ); + const [currencySymbol, setCurrencySymbol] = React.useState<{ - symbol: string - scale: number - }>({ symbol: '$', scale: 2 }) - - const getWalletCurrency = useCallback( - async (walletAddressUrl: string): Promise => { - clearErrors('walletAddressUrl') - if (!walletAddressUrl) return + symbol: string; + scale: number; + }>({ symbol: '$', scale: 2 }); + + const getWalletInformation = React.useCallback( + async (walletAddressUrl: string): Promise => { + setErrors((prev) => ({ ...prev, walletAddressUrl: null })); + if (!walletAddressUrl) return false; try { - const url = new URL(toWalletAddressUrl(walletAddressUrl)) - const walletAddress = await getWalletInformation(url.toString()) - setCurrencySymbol({ - symbol: getCurrencySymbol(walletAddress.assetCode), - scale: walletAddress.assetScale - }) - } catch { - setError('walletAddressUrl', { - type: 'validate', - message: 'Invalid wallet address.' - }) + setIsValidating((_) => ({ ..._, walletAddressUrl: true })); + const url = new URL(toWalletAddressUrl(walletAddressUrl)); + const walletAddress = await getWalletInfo(url.toString()); + setWalletAddressInfo(walletAddress); + } catch (error) { + setErrors((prev) => ({ + ...prev, + walletAddressUrl: toErrorInfo(error.message), + })); + return false; + } finally { + setIsValidating((_) => ({ ..._, walletAddressUrl: false })); } + return true; }, - [clearErrors, setError] - ) - - const handleOnChangeAmount = async ( - e: React.ChangeEvent - ) => { - const amountValue = formatNumber( - +e.currentTarget.value, - currencySymbol.scale - ) - debounceSync(() => { - localStorage?.setItem('amountValue', amountValue) - }, 100)() - } + [getWalletInfo, toErrorInfo], + ); - const handleOnChangeWalletAddressUrl = async ( - e: React.ChangeEvent - ) => { - const walletAddressUrl = e.currentTarget.value - debounceSync(() => { - localStorage?.setItem('walletAddressUrl', walletAddressUrl) - }, 100)() - } + const handleWalletAddressUrlChange = React.useCallback( + async (value: string, _input?: HTMLInputElement) => { + setWalletAddressInfo(null); + setWalletAddressUrl(value); - const handleOnChangeRecurring = (e: React.ChangeEvent) => { - const recurring = e.currentTarget.checked - debounceSync( - () => localStorage?.setItem('recurring', `${recurring}`), - 100 - )() - } + const error = validateWalletAddressUrl(value); + setErrors((prev) => ({ ...prev, walletAddressUrl: toErrorInfo(error) })); + saveValue('walletAddressUrl', value); + if (!error) { + const ok = await getWalletInformation(value); + return ok; + } + return false; + }, + [saveValue, getWalletInformation, toErrorInfo], + ); + + const handleAmountChange = React.useCallback( + (value: string, input: HTMLInputElement) => { + const error = validateAmount(value, currencySymbol.symbol); + setErrors((prev) => ({ ...prev, amount: toErrorInfo(error) })); + + const amountValue = formatNumber(+value, currencySymbol.scale); + if (!error) { + setAmount(amountValue); + input.value = amountValue; + } + saveValue('amount', error ? value : amountValue); + }, + [saveValue, currencySymbol, toErrorInfo], + ); + + const handleSubmit = async (ev?: React.FormEvent) => { + ev?.preventDefault(); - useEffect(() => { - const walletAddressUrl = - localStorage?.getItem('walletAddressUrl') || undefined - if (!walletAddressUrl) return - getWalletCurrency(walletAddressUrl) - }, [getWalletCurrency]) + const errWalletAddressUrl = validateWalletAddressUrl(walletAddressUrl); + const errAmount = validateAmount(amount, currencySymbol.symbol); + if (errAmount || errWalletAddressUrl) { + setErrors((prev) => ({ + ...prev, + walletAddressUrl: toErrorInfo(errWalletAddressUrl), + amount: toErrorInfo(errAmount), + })); + return; + } + + try { + setIsSubmitting(true); + let skipAutoKeyShare = autoKeyShareFailed; + if (errors.keyPair) { + skipAutoKeyShare = true; + setAutoKeyShareFailed(true); + } + setErrors((prev) => ({ ...prev, keyPair: null, connect: null })); + const res = await connectWallet({ + walletAddressUrl: toWalletAddressUrl(walletAddressUrl), + amount, + recurring, + autoKeyAdd: !skipAutoKeyShare, + autoKeyAddConsent: autoKeyAddConsent.current, + }); + if (res.success) { + onConnect(); + } else { + if (isErrorWithKey(res.error)) { + const error = res.error; + if (error.key.startsWith('connectWalletKeyService_error_')) { + if (error.key === 'connectWalletKeyService_error_noConsent') { + setShowConsent(true); + return; + } + setErrors((prev) => ({ ...prev, keyPair: toErrorInfo(error) })); + } else { + setErrors((prev) => ({ ...prev, connect: toErrorInfo(error) })); + } + } else { + throw new Error(res.message); + } + } + } catch (error) { + setErrors((prev) => ({ ...prev, connect: toErrorInfo(error.message) })); + } finally { + setIsSubmitting(false); + } + }; + + React.useEffect(() => { + if (!walletAddressInfo) return; + setCurrencySymbol({ + symbol: getCurrencySymbol(walletAddressInfo.assetCode), + scale: walletAddressInfo.assetScale, + }); + }, [walletAddressInfo]); + + React.useEffect(() => { + if (defaultValues.walletAddressUrl) { + handleWalletAddressUrlChange(defaultValues.walletAddressUrl); + } + }, [defaultValues.walletAddressUrl, handleWalletAddressUrlChange]); + + if (showConsent) { + return ( + { + autoKeyAddConsent.current = true; + // saveValue('autoKeyAddConsent', true); + setShowConsent(false); + handleSubmit(); + }} + onDecline={() => { + const error = errorWithKey('connectWalletKeyService_error_noConsent'); + setErrors((prev) => ({ ...prev, keyPair: toErrorInfo(error) })); + setShowConsent(false); + }} + /> + ); + } return (
{ - const response = await connectWallet({ - ...data, - walletAddressUrl: toWalletAddressUrl(data.walletAddressUrl) - }) - if (!response.success) { - setError('walletAddressUrl', { - type: 'validate', - message: response.message - }) - } - })} - className="space-y-4" + data-testid="connect-wallet-form" + className="flex flex-col gap-4" + onSubmit={handleSubmit} > -
- -

- Get a wallet address from a provider before connecting it below. - Please find a list of available wallets{' '} - - here - - . -

- Copy the public key below and paste it into your wallet. +

+

+ {t('connectWallet_text_title')} +

+

+ {t('connectWallet_text_desc')}

-
+ + {errors.connect && ( + + )} + ) => { - getWalletCurrency(e.currentTarget.value) - }, - onChange: handleOnChangeWalletAddressUrl - })} - /> - { - if ( - !charIsNumber(e.key) && - e.key !== 'Backspace' && - e.key !== 'Delete' && - e.key !== 'Tab' - ) { - e.preventDefault() + defaultValue={walletAddressUrl} + addOn={ + isValidating.walletAddressUrl ? ( + + ) : null + } + addOnPosition="right" + required={true} + autoComplete="on" + spellCheck={false} + enterKeyHint="go" + readOnly={isSubmitting} + onPaste={async (ev) => { + const input = ev.currentTarget; + let value = ev.clipboardData.getData('text'); + if (!value) return; + if (!validateWalletAddressUrl(value)) { + ev.preventDefault(); // full url was pasted + } else { + await sleep(0); // allow paste to be complete + value = input.value; } + if (value === walletAddressUrl) { + if (value || !input.required) { + return; + } + } + const ok = await handleWalletAddressUrlChange(value, input); + resetState(); + if (ok) document.getElementById('connectAmount')?.focus(); + }} + onBlur={async (ev) => { + const value = ev.currentTarget.value; + if (value === walletAddressUrl) { + if (value || !ev.currentTarget.required) { + return; + } + } + await handleWalletAddressUrlChange(value, ev.currentTarget); + resetState(); }} - errorMessage={errors.amount?.message} - {...register('amount', { - required: { value: true, message: 'Amount is required.' }, - valueAsNumber: false, - onBlur: (e: React.FocusEvent) => { - setValue( - 'amount', - formatNumber(+e.currentTarget.value, currencySymbol.scale) - ) - }, - onChange: handleOnChangeAmount - })} /> -
- + + {t('connectWallet_labelGroup_amount')} + +
+ {currencySymbol.symbol}} + aria-invalid={!!errors.amount} + aria-describedby={errors.amount?.message} + required={true} + onKeyDown={allowOnlyNumericInput} + onBlur={(ev) => { + const value = ev.currentTarget.value; + if (value === amount && !ev.currentTarget.required) { + return; + } + handleAmountChange(value, ev.currentTarget); + }} + /> + + { + const value = ev.currentTarget.checked; + setRecurring(value); + saveValue('recurring', value); + }} + /> +
+ + {errors.amount && ( +

{errors.amount.message}

+ )} + + + {(errors.keyPair || autoKeyShareFailed) && ( + -
- + +
- ) + ); +}; + +const AutoKeyAddConsent: React.FC<{ + onAccept: () => void; + onDecline: () => void; +}> = ({ onAccept, onDecline }) => { + const t = useTranslation(); + return ( +
+

+ {t('connectWalletKeyService_text_consentP1')}{' '} + +

+ +
+

{t('connectWalletKeyService_text_consentP2')}

+

{t('connectWalletKeyService_text_consentP3')}

+
+ +
+ + +
+
+ ); +}; + +const ManualKeyPairNeeded: React.FC<{ + error: { message: string; details: null | ErrorInfo; whyText: string }; + hideError?: boolean; + retry: () => Promise; + text: string; + learnMoreText: string; + publicKey: string; +}> = ({ error, hideError, text, learnMoreText, publicKey, retry }) => { + const ErrorDetails = () => { + if (!error || !error.details) return null; + return ( +
+ + {error.whyText} + + {error.details.message} + {canRetryAutoKeyAdd(error.details.info) && ( + + )} +
+ ); + }; + + return ( +
+ {!hideError && ( +
+ {error.message} +
+ )} +

+ {text}{' '} + + {learnMoreText} + +

+ +
+ ); +}; + +function isAutoKeyAddFailed(state: PopupTransientState['connect']) { + if (state?.status === 'error') { + return ( + isErrorWithKey(state.error) && + state.error.key !== 'connectWallet_error_tabClosed' + ); + } else if (state?.status === 'error:key') { + return ( + isErrorWithKey(state.error) && + state.error.key.startsWith('connectWalletKeyService_error_') + ); + } + return false; +} + +function canRetryAutoKeyAdd(err?: ErrorInfo['info']) { + if (!err) return false; + return ( + err.key === 'connectWalletKeyService_error_noConsent' || + err.cause?.key === 'connectWalletKeyService_error_timeoutLogin' || + err.cause?.key === 'connectWalletKeyService_error_accountNotFound' + ); +} + +function validateWalletAddressUrl(value: string): null | ErrorWithKeyLike { + if (!value) { + return errorWithKey('connectWallet_error_urlRequired'); + } + let url: URL; + try { + url = new URL(toWalletAddressUrl(value)); + } catch { + return errorWithKey('connectWallet_error_urlInvalidUrl'); + } + + if (url.protocol !== 'https:') { + return errorWithKey('connectWallet_error_urlInvalidNotHttps'); + } + + return null; +} + +function validateAmount( + value: string, + currencySymbol: string, +): null | ErrorWithKeyLike { + if (!value) { + return errorWithKey('connectWallet_error_amountRequired'); + } + const val = Number(value); + if (Number.isNaN(val)) { + return errorWithKey('connectWallet_error_amountInvalidNumber', [ + `${currencySymbol}${value}`, + ]); + } + if (val <= 0) { + return errorWithKey('connectWallet_error_amountMinimum'); + } + return null; +} + +function allowOnlyNumericInput(ev: React.KeyboardEvent) { + if ( + (!charIsNumber(ev.key) && + ev.key !== 'Backspace' && + ev.key !== 'Delete' && + ev.key !== 'Enter' && + ev.key !== 'Tab') || + (ev.key === '.' && ev.currentTarget.value.includes('.')) + ) { + ev.preventDefault(); + } } diff --git a/src/popup/components/ErrorKeyRevoked.tsx b/src/popup/components/ErrorKeyRevoked.tsx index 3d83ab2c..954c6162 100644 --- a/src/popup/components/ErrorKeyRevoked.tsx +++ b/src/popup/components/ErrorKeyRevoked.tsx @@ -1,36 +1,36 @@ -import React from 'react' -import { useForm } from 'react-hook-form' -import { AnimatePresence, m } from 'framer-motion' -import { WarningSign } from '@/popup/components/Icons' -import { Button } from '@/popup/components/ui/Button' -import { Code } from '@/popup/components/ui/Code' -import { useTranslation } from '@/popup/lib/context' -import { useLocalStorage } from '@/popup/lib/hooks' -import type { PopupStore } from '@/shared/types' -import type { Response } from '@/shared/messages' +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { AnimatePresence, m } from 'framer-motion'; +import { WarningSign } from '@/popup/components/Icons'; +import { Button } from '@/popup/components/ui/Button'; +import { Code } from '@/popup/components/ui/Code'; +import { useTranslation } from '@/popup/lib/context'; +import { useLocalStorage } from '@/popup/lib/hooks'; +import type { PopupStore } from '@/shared/types'; +import type { Response } from '@/shared/messages'; interface Props { - info: Pick - disconnectWallet: () => Promise - reconnectWallet: () => Promise - onReconnect?: () => void - onDisconnect?: () => void + info: Pick; + disconnectWallet: () => Promise; + reconnectWallet: () => Promise; + onReconnect?: () => void; + onDisconnect?: () => void; } -type Screen = 'main' | 'reconnect' +type Screen = 'main' | 'reconnect'; export const ErrorKeyRevoked = ({ info, disconnectWallet, reconnectWallet, onReconnect, - onDisconnect + onDisconnect, }: Props) => { const [screen, setScreen, clearScreen] = useLocalStorage( 'keyRevokedScreen', 'main', - { maxAge: 2 * 60 } - ) + { maxAge: 2 * 60 }, + ); if (screen === 'main') { return ( @@ -41,7 +41,7 @@ export const ErrorKeyRevoked = ({ onDisconnect={onDisconnect} /> - ) + ); } else { return ( @@ -49,41 +49,41 @@ export const ErrorKeyRevoked = ({ info={info} reconnectWallet={reconnectWallet} onReconnect={() => { - clearScreen() - onReconnect?.() + clearScreen(); + onReconnect?.(); }} /> - ) + ); } -} +}; interface MainScreenProps { - disconnectWallet: Props['disconnectWallet'] - onDisconnect?: Props['onDisconnect'] - requestReconnect: () => void + disconnectWallet: Props['disconnectWallet']; + onDisconnect?: Props['onDisconnect']; + requestReconnect: () => void; } const MainScreen = ({ disconnectWallet, onDisconnect, - requestReconnect + requestReconnect, }: MainScreenProps) => { - const t = useTranslation() - const [errorMsg, setErrorMsg] = React.useState('') - const [loading, setIsLoading] = React.useState(false) + const t = useTranslation(); + const [errorMsg, setErrorMsg] = React.useState(''); + const [loading, setIsLoading] = React.useState(false); const requestDisconnect = async () => { - setErrorMsg('') + setErrorMsg(''); try { - setIsLoading(true) - await disconnectWallet() - onDisconnect?.() + setIsLoading(true); + await disconnectWallet(); + onDisconnect?.(); } catch (error) { - setErrorMsg(error.message) + setErrorMsg(error.message); } - setIsLoading(false) - } + setIsLoading(false); + }; return ( @@ -115,41 +115,41 @@ const MainScreen = ({ - ) -} + ); +}; interface ReconnectScreenProps { - info: Props['info'] - reconnectWallet: Props['reconnectWallet'] - onReconnect?: Props['onDisconnect'] + info: Props['info']; + reconnectWallet: Props['reconnectWallet']; + onReconnect?: Props['onDisconnect']; } const ReconnectScreen = ({ info, reconnectWallet, - onReconnect + onReconnect, }: ReconnectScreenProps) => { - const t = useTranslation() + const t = useTranslation(); const { handleSubmit, formState: { errors, isSubmitting }, clearErrors, - setError - } = useForm({ criteriaMode: 'firstError', mode: 'onSubmit' }) + setError, + } = useForm({ criteriaMode: 'firstError', mode: 'onSubmit' }); const requestReconnect = async () => { - clearErrors() + clearErrors(); try { - const res = await reconnectWallet() + const res = await reconnectWallet(); if (res.success) { - onReconnect?.() + onReconnect?.(); } else { - setError('root', { message: res.message }) + setError('root', { message: res.message }); } } catch (error) { - setError('root', { message: error.message }) + setError('root', { message: error.message }); } - } + }; return ( - ) -} + ); +}; diff --git a/src/popup/components/ErrorMessage.tsx b/src/popup/components/ErrorMessage.tsx index 060006a2..c6f1cfd8 100644 --- a/src/popup/components/ErrorMessage.tsx +++ b/src/popup/components/ErrorMessage.tsx @@ -1,22 +1,24 @@ -import React from 'react' -import { XIcon } from './Icons' -import { cn } from '@/shared/helpers' +import React from 'react'; +import { XIcon } from './Icons'; +import { cn } from '@/shared/helpers'; interface ErrorMessageProps extends React.HTMLAttributes { - error?: string + error?: string; } export const ErrorMessage = React.forwardRef( ({ error, className, children, ...props }, ref) => { - if (!error) return null + if (!error) return null; return (
@@ -24,8 +26,8 @@ export const ErrorMessage = React.forwardRef( {children}
- ) - } -) + ); + }, +); -ErrorMessage.displayName = 'ErrorMessage' +ErrorMessage.displayName = 'ErrorMessage'; diff --git a/src/popup/components/Icons.tsx b/src/popup/components/Icons.tsx index 149e5308..36a951a5 100644 --- a/src/popup/components/Icons.tsx +++ b/src/popup/components/Icons.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React from 'react'; export const Spinner = (props: React.SVGProps) => { return ( @@ -16,8 +16,8 @@ export const Spinner = (props: React.SVGProps) => { d="M2.204 6.447A6 6 0 108 2" /> - ) -} + ); +}; export const ArrowBack = (props: React.SVGProps) => { return ( @@ -46,8 +46,8 @@ export const ArrowBack = (props: React.SVGProps) => { /> - ) -} + ); +}; export const Settings = (props: React.SVGProps) => { return ( @@ -76,8 +76,8 @@ export const Settings = (props: React.SVGProps) => { /> - ) -} + ); +}; export const DollarSign = (props: React.SVGProps) => { return ( @@ -110,8 +110,8 @@ export const DollarSign = (props: React.SVGProps) => { - ) -} + ); +}; export const WarningSign = (props: React.SVGProps) => { return ( @@ -141,8 +141,8 @@ export const WarningSign = (props: React.SVGProps) => { /> - ) -} + ); +}; export const ClipboardIcon = (props: React.SVGProps) => { return ( @@ -159,8 +159,8 @@ export const ClipboardIcon = (props: React.SVGProps) => { fill="currentColor" /> - ) -} + ); +}; export const CheckIcon = (props: React.SVGProps) => { return ( @@ -179,8 +179,8 @@ export const CheckIcon = (props: React.SVGProps) => { d="m4.5 12.75 6 6 9-13.5" /> - ) -} + ); +}; export const XIcon = (props: React.SVGProps) => { return ( @@ -199,5 +199,5 @@ export const XIcon = (props: React.SVGProps) => { d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> - ) -} + ); +}; diff --git a/src/popup/components/LoadingSpinner.tsx b/src/popup/components/LoadingSpinner.tsx index 5e6e10b0..6b90c5ab 100644 --- a/src/popup/components/LoadingSpinner.tsx +++ b/src/popup/components/LoadingSpinner.tsx @@ -1,22 +1,27 @@ -import { type VariantProps, cva } from 'class-variance-authority' -import React from 'react' +import { type VariantProps, cva } from 'class-variance-authority'; +import React from 'react'; -import { Spinner } from '@/popup/components/Icons' +import { Spinner } from '@/popup/components/Icons'; -const loadingSpinnerStyles = cva('animate-spin text-white', { +const loadingSpinnerStyles = cva('animate-spin', { variants: { - variant: { + size: { md: 'h-4 w-4', - lg: 'h-6 w-6' - } + lg: 'h-6 w-6', + }, + color: { + white: 'text-white', + gray: 'text-gray-300', + }, }, defaultVariants: { - variant: 'lg' - } -}) + size: 'lg', + color: 'white', + }, +}); -export type LoadingIndicatorProps = VariantProps +export type LoadingIndicatorProps = VariantProps; -export const LoadingSpinner = ({ variant }: LoadingIndicatorProps) => { - return -} +export const LoadingSpinner = ({ size, color }: LoadingIndicatorProps) => { + return ; +}; diff --git a/src/popup/components/NotMonetized.tsx b/src/popup/components/NotMonetized.tsx new file mode 100644 index 00000000..f01f39fc --- /dev/null +++ b/src/popup/components/NotMonetized.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { WarningSign } from '@/popup/components/Icons'; + +export const NotMonetized = ({ text }: { text: string }) => { + return ( +
+
+ +
+

{text}

+
+ ); +}; diff --git a/src/popup/components/OutOfFunds.tsx b/src/popup/components/OutOfFunds.tsx index ca7367bd..4ad9ea06 100644 --- a/src/popup/components/OutOfFunds.tsx +++ b/src/popup/components/OutOfFunds.tsx @@ -1,37 +1,37 @@ -import React from 'react' -import { useForm } from 'react-hook-form' -import type { RecurringGrant, OneTimeGrant, AmountValue } from '@/shared/types' -import type { AddFundsPayload, Response } from '@/shared/messages' -import type { WalletAddress } from '@interledger/open-payments' +import React from 'react'; +import { useForm } from 'react-hook-form'; +import type { RecurringGrant, OneTimeGrant, AmountValue } from '@/shared/types'; +import type { AddFundsPayload, Response } from '@/shared/messages'; +import type { WalletAddress } from '@interledger/open-payments'; import { charIsNumber, formatNumber, getCurrencySymbol, - transformBalance -} from '@/popup/lib/utils' -import { useTranslation } from '@/popup/lib/context' -import { getNextOccurrence } from '@/shared/helpers' -import { ErrorMessage } from '@/popup/components/ErrorMessage' -import { Button } from '@/popup/components/ui/Button' -import { Input } from '@/popup/components/ui/Input' + transformBalance, +} from '@/popup/lib/utils'; +import { useTranslation } from '@/popup/lib/context'; +import { getNextOccurrence } from '@/shared/helpers'; +import { ErrorMessage } from '@/popup/components/ErrorMessage'; +import { Button } from '@/popup/components/ui/Button'; +import { Input } from '@/popup/components/ui/Input'; interface OutOfFundsProps { - info: Pick - grantRecurring?: RecurringGrant['amount'] - grantOneTime?: OneTimeGrant['amount'] - onChooseOption: (recurring: boolean) => void + info: Pick; + grantRecurring?: RecurringGrant['amount']; + grantOneTime?: OneTimeGrant['amount']; + onChooseOption: (recurring: boolean) => void; } export const OutOfFunds = ({ info, grantOneTime, grantRecurring, - onChooseOption + onChooseOption, }: OutOfFundsProps) => { if (!grantOneTime && !grantRecurring) { - throw new Error('Provide at least one of grantOneTime and grantRecurring') + throw new Error('Provide at least one of grantOneTime and grantRecurring'); } - const t = useTranslation() + const t = useTranslation(); return (
@@ -61,39 +61,39 @@ export const OutOfFunds = ({ {t('outOfFunds_action_optionOneTime')}
- ) -} + ); +}; interface AddFundsProps { - info: Pick - recurring: boolean - defaultAmount: AmountValue - requestAddFunds: (details: AddFundsPayload) => Promise + info: Pick; + recurring: boolean; + defaultAmount: AmountValue; + requestAddFunds: (details: AddFundsPayload) => Promise; } export function AddFunds({ info, defaultAmount, recurring, - requestAddFunds + requestAddFunds, }: AddFundsProps) { - const t = useTranslation() + const t = useTranslation(); const { register, handleSubmit, formState: { errors, isSubmitting }, setError, - setValue + setValue, } = useForm({ criteriaMode: 'firstError', mode: 'onSubmit', reValidateMode: 'onBlur', defaultValues: { - amount: transformBalance(defaultAmount, info.assetScale) - } - }) + amount: transformBalance(defaultAmount, info.assetScale), + }, + }); - const currencySymbol = getCurrencySymbol(info.assetCode) + const currencySymbol = getCurrencySymbol(info.assetCode); return (
{ const response = await requestAddFunds({ amount: data.amount, - recurring: !!recurring - }) + recurring: !!recurring, + }); if (!response.success) { - setError('root', { message: response.message }) + setError('root', { message: response.message }); } })} > @@ -124,7 +124,7 @@ export function AddFunds({ description={ recurring ? t('outOfFundsAddFunds_label_amountDescriptionRecurring', [ - getNextOccurrenceDate('P1M') + getNextOccurrenceDate('P1M'), ]) : t('outOfFundsAddFunds_label_amountDescriptionOneTime') } @@ -138,7 +138,7 @@ export function AddFunds({ e.key !== 'Delete' && e.key !== 'Tab' ) { - e.preventDefault() + e.preventDefault(); } }} errorMessage={errors.amount?.message} @@ -146,8 +146,8 @@ export function AddFunds({ required: { value: true, message: 'Amount is required.' }, valueAsNumber: false, onBlur: (e: React.FocusEvent) => { - setValue('amount', formatNumber(+e.currentTarget.value, 2)) - } + setValue('amount', formatNumber(+e.currentTarget.value, 2)); + }, })} /> @@ -164,35 +164,35 @@ export function AddFunds({ : t('outOfFundsAddFunds_action_addOneTime')} - ) + ); } function RecurringAutoRenewInfo({ grantRecurring, - info + info, }: Pick) { - const t = useTranslation() + const t = useTranslation(); - if (!grantRecurring) return null + if (!grantRecurring) return null; - const currencySymbol = getCurrencySymbol(info.assetCode) - const amount = transformBalance(grantRecurring.value, info.assetScale) - const renewDate = getNextOccurrence(grantRecurring.interval, new Date()) + const currencySymbol = getCurrencySymbol(info.assetCode); + const amount = transformBalance(grantRecurring.value, info.assetScale); + const renewDate = getNextOccurrence(grantRecurring.interval, new Date()); const renewDateLocalized = renewDate.toLocaleString(undefined, { dateStyle: 'medium', - timeStyle: 'short' - }) + timeStyle: 'short', + }); return t('outOfFunds_error_textDoNothing', [ `${currencySymbol}${amount}`, - renewDateLocalized - ]) + renewDateLocalized, + ]); } function getNextOccurrenceDate(period: 'P1M', baseDate = new Date()) { const date = getNextOccurrence( `R/${baseDate.toISOString()}/${period}`, - baseDate - ) - return date.toLocaleDateString(undefined, { dateStyle: 'medium' }) + baseDate, + ); + return date.toLocaleDateString(undefined, { dateStyle: 'medium' }); } diff --git a/src/popup/components/PayWebsiteForm.tsx b/src/popup/components/PayWebsiteForm.tsx index 993221ec..312f9330 100644 --- a/src/popup/components/PayWebsiteForm.tsx +++ b/src/popup/components/PayWebsiteForm.tsx @@ -1,62 +1,63 @@ -import { Button } from '@/popup/components/ui/Button' -import { Input } from '@/popup/components/ui/Input' -import { PopupStateContext } from '@/popup/lib/context' -import { payWebsite } from '@/popup/lib/messages' +import { Button } from '@/popup/components/ui/Button'; +import { Input } from '@/popup/components/ui/Input'; +import { useMessage, usePopupState } from '@/popup/lib/context'; import { getCurrencySymbol, charIsNumber, - formatNumber -} from '@/popup/lib/utils' -import React, { useMemo } from 'react' -import { useForm } from 'react-hook-form' -import { AnimatePresence, m } from 'framer-motion' -import { Spinner } from './Icons' -import { cn } from '@/shared/helpers' -import { ErrorMessage } from './ErrorMessage' + formatNumber, +} from '@/popup/lib/utils'; +import React, { useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { AnimatePresence, m } from 'framer-motion'; +import { Spinner } from './Icons'; +import { cn } from '@/shared/helpers'; +import { ErrorMessage } from './ErrorMessage'; interface PayWebsiteFormProps { - amount: string + amount: string; } const BUTTON_STATE = { idle: 'Send now', loading: , - success: 'Payment successful' -} + success: 'Payment successful', +}; export const PayWebsiteForm = () => { - const [buttonState, setButtonState] = - React.useState('idle') - const isIdle = useMemo(() => buttonState === 'idle', [buttonState]) + const message = useMessage(); const { - state: { walletAddress, url } - } = React.useContext(PopupStateContext) + state: { walletAddress, tab }, + } = usePopupState(); + const [buttonState, setButtonState] = + React.useState('idle'); + const isIdle = useMemo(() => buttonState === 'idle', [buttonState]); + const { register, formState: { errors, isSubmitting }, setValue, handleSubmit, ...form - } = useForm() + } = useForm(); const onSubmit = handleSubmit(async (data) => { - if (buttonState !== 'idle') return + if (buttonState !== 'idle') return; - setButtonState('loading') + setButtonState('loading'); - const response = await payWebsite(data) + const response = await message.send('PAY_WEBSITE', { amount: data.amount }); if (!response.success) { - setButtonState('idle') - form.setError('root', { message: response.message }) + setButtonState('idle'); + form.setError('root', { message: response.message }); } else { - setButtonState('success') - form.reset() + setButtonState('success'); + form.reset(); setTimeout(() => { - setButtonState('idle') - }, 2000) + setButtonState('idle'); + }, 2000); } - }) + }); return (
@@ -65,7 +66,7 @@ export const PayWebsiteForm = () => { { addOn={getCurrencySymbol(walletAddress.assetCode)} label={

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

} placeholder="0.00" onKeyDown={(e) => { if (e.key === 'Enter') { - e.currentTarget.blur() - onSubmit() + e.currentTarget.blur(); + onSubmit(); } else if ( !charIsNumber(e.key) && e.key !== 'Backspace' && e.key !== 'Delete' && e.key !== 'Tab' ) { - e.preventDefault() + e.preventDefault(); } }} errorMessage={errors.amount?.message} @@ -107,9 +108,9 @@ export const PayWebsiteForm = () => { onBlur: (e: React.FocusEvent) => { setValue( 'amount', - formatNumber(+e.currentTarget.value, walletAddress.assetScale) - ) - } + formatNumber(+e.currentTarget.value, walletAddress.assetScale), + ); + }, })} /> - ) -} + ); +}; diff --git a/src/popup/components/ProtectedRoute.tsx b/src/popup/components/ProtectedRoute.tsx index a5f3c4e1..049eebb0 100644 --- a/src/popup/components/ProtectedRoute.tsx +++ b/src/popup/components/ProtectedRoute.tsx @@ -1,23 +1,23 @@ -import { PopupStateContext } from '@/popup/lib/context' -import React from 'react' -import { Navigate, Outlet } from 'react-router-dom' -import { ROUTES_PATH } from '../Popup' +import { usePopupState } from '@/popup/lib/context'; +import React from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { ROUTES_PATH } from '../Popup'; export const ProtectedRoute = () => { - const { state } = React.useContext(PopupStateContext) + const { state } = usePopupState(); if (state.state.missing_host_permissions) { - return + return ; } if (state.state.key_revoked) { - return + return ; } if (state.state.out_of_funds) { - return + return ; } if (state.connected === false) { - return + return ; } - return -} + return ; +}; diff --git a/src/popup/components/SiteNotMonetized.tsx b/src/popup/components/SiteNotMonetized.tsx deleted file mode 100644 index 569ff00e..00000000 --- a/src/popup/components/SiteNotMonetized.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react' -import { WarningSign } from '@/popup/components/Icons' -import { useTranslation } from '@/popup/lib/context' - -export const SiteNotMonetized = () => { - const t = useTranslation() - return ( -
-
- -
-

{t('siteNotMonetized_state_text')}

-
- ) -} diff --git a/src/popup/components/WalletInformation.tsx b/src/popup/components/WalletInformation.tsx index cb1cde07..5f302f7d 100644 --- a/src/popup/components/WalletInformation.tsx +++ b/src/popup/components/WalletInformation.tsx @@ -1,21 +1,22 @@ -import { Input } from '@/popup/components/ui/Input' -import { Label } from '@/popup/components/ui/Label' -import React from 'react' -import { Code } from '@/popup/components/ui/Code' -import { PopupStore } from '@/shared/types' -import { Button } from '@/popup/components/ui/Button' -import { disconnectWallet } from '@/popup/lib/messages' -import { useForm } from 'react-hook-form' +import { Input } from '@/popup/components/ui/Input'; +import { Label } from '@/popup/components/ui/Label'; +import React from 'react'; +import { Code } from '@/popup/components/ui/Code'; +import { PopupStore } from '@/shared/types'; +import { Button } from '@/popup/components/ui/Button'; +import { useMessage } from '@/popup/lib/context'; +import { useForm } from 'react-hook-form'; interface WalletInformationProps { - info: PopupStore + info: PopupStore; } export const WalletInformation = ({ info }: WalletInformationProps) => { + const message = useMessage(); const { handleSubmit, - formState: { isSubmitting } - } = useForm() + formState: { isSubmitting }, + } = useForm(); return (
@@ -36,15 +37,15 @@ export const WalletInformation = ({ info }: WalletInformationProps) => { {/* TODO: Improve error handling */}
{ - await disconnectWallet() - window.location.reload() + await message.send('DISCONNECT_WALLET'); + window.location.reload(); })} >
- ) -} + ); +}; diff --git a/src/popup/components/WarningMessage.tsx b/src/popup/components/WarningMessage.tsx index aac52ac1..7d2c01b4 100644 --- a/src/popup/components/WarningMessage.tsx +++ b/src/popup/components/WarningMessage.tsx @@ -1,15 +1,15 @@ -import React from 'react' -import { WarningSign } from './Icons' -import { cn } from '@/shared/helpers' +import React from 'react'; +import { WarningSign } from './Icons'; +import { cn } from '@/shared/helpers'; interface WarningMessageProps extends React.HTMLAttributes { - warning?: string + warning?: string; } export const WarningMessage = React.forwardRef< HTMLDivElement, WarningMessageProps >(({ warning, className, children, ...props }, ref) => { - if (!warning) return null + if (!warning) return null; return (
@@ -26,7 +26,7 @@ export const WarningMessage = React.forwardRef< {children}
- ) -}) + ); +}); -WarningMessage.displayName = 'WarningMessage' +WarningMessage.displayName = 'WarningMessage'; diff --git a/src/popup/components/layout/Header.tsx b/src/popup/components/layout/Header.tsx index eb6e1645..cf7ede96 100644 --- a/src/popup/components/layout/Header.tsx +++ b/src/popup/components/layout/Header.tsx @@ -1,23 +1,24 @@ -import React, { useContext, useMemo } from 'react' -import { Link, useLocation } from 'react-router-dom' -import { ArrowBack, Settings } from '../Icons' -import { ROUTES_PATH } from '@/popup/Popup' -import { PopupStateContext, useBrowser } from '@/popup/lib/context' +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { ArrowBack, Settings } from '../Icons'; +import { HeaderEmpty } from './HeaderEmpty'; +import { ROUTES_PATH } from '@/popup/Popup'; +import { useBrowser, usePopupState } from '@/popup/lib/context'; const NavigationButton = () => { - const location = useLocation() + const location = useLocation(); const { - state: { connected } - } = useContext(PopupStateContext) - return useMemo(() => { - if (!connected) return null + state: { connected }, + } = usePopupState(); + return React.useMemo(() => { + if (!connected) return null; if (location.pathname.includes('/s/')) { return ( - ) + ); } return location.pathname === `${ROUTES_PATH.SETTINGS}` ? ( @@ -28,23 +29,17 @@ const NavigationButton = () => { - ) - }, [location, connected]) -} + ); + }, [location, connected]); +}; export const Header = () => { - const browser = useBrowser() - const Logo = browser.runtime.getURL('assets/images/logo.svg') + const browser = useBrowser(); + const Logo = browser.runtime.getURL('assets/images/logo.svg'); return ( -
-
- Web Monetization Logo -

Web Monetization

-
-
- -
-
- ) -} + + + + ); +}; diff --git a/src/popup/components/layout/HeaderEmpty.tsx b/src/popup/components/layout/HeaderEmpty.tsx new file mode 100644 index 00000000..0b7b9487 --- /dev/null +++ b/src/popup/components/layout/HeaderEmpty.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +export const HeaderEmpty = ({ + logo, + children, +}: React.PropsWithChildren<{ logo: string }>) => { + return ( +
+
+ Web Monetization Logo +

Web Monetization

+
+
{children}
+
+ ); +}; diff --git a/src/popup/components/layout/MainLayout.tsx b/src/popup/components/layout/MainLayout.tsx index e4e175ac..5b5c3650 100644 --- a/src/popup/components/layout/MainLayout.tsx +++ b/src/popup/components/layout/MainLayout.tsx @@ -1,20 +1,23 @@ -import React from 'react' -import { Outlet } from 'react-router-dom' +import React from 'react'; +import { Outlet } from 'react-router-dom'; -import { Header } from './Header' +import { Header } from './Header'; const Divider = () => { - return
-} + return
; +}; export const MainLayout = () => { return ( -
+
- ) -} + ); +}; diff --git a/src/popup/components/ui/Button.tsx b/src/popup/components/ui/Button.tsx index 2ed857cb..bc21e6fd 100644 --- a/src/popup/components/ui/Button.tsx +++ b/src/popup/components/ui/Button.tsx @@ -1,14 +1,14 @@ -import { type VariantProps, cva } from 'class-variance-authority' -import React, { forwardRef } from 'react' +import { type VariantProps, cva } from 'class-variance-authority'; +import React, { forwardRef } from 'react'; -import { LoadingSpinner } from '@/popup/components/LoadingSpinner' -import { cn } from '@/shared/helpers' +import { LoadingSpinner } from '@/popup/components/LoadingSpinner'; +import { cn } from '@/shared/helpers'; const buttonVariants = cva( [ 'relative inline-flex items-center justify-center whitespace-nowrap rounded-xl font-semibold', 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500', - 'disabled:pointer-events-none disabled:select-none disabled:opacity-50' + 'disabled:pointer-events-none disabled:select-none disabled:opacity-50', ], { @@ -16,32 +16,32 @@ const buttonVariants = cva( variant: { default: 'bg-button-base text-white hover:bg-button-base-hover', destructive: 'bg-error text-error hover:bg-error-hover', - ghost: '' + ghost: '', }, size: { default: 'px-6 py-4 font-medium', - icon: 'h-6 w-6' + icon: 'h-6 w-6', }, fullWidth: { - true: 'w-full' + true: 'w-full', }, loading: { - true: 'text-transparent' - } + true: 'text-transparent', + }, }, defaultVariants: { variant: 'default', - size: 'default' - } - } -) + size: 'default', + }, + }, +); export interface ButtonProps extends VariantProps, React.ButtonHTMLAttributes { - loading?: boolean + loading?: boolean; /** Optional only when children are passed */ - ['aria-label']?: string + ['aria-label']?: string; } export const Button = forwardRef( @@ -56,7 +56,7 @@ export const Button = forwardRef( children, ...props }, - ref + ref, ) { return ( - ) - } -) + ); + }, +); diff --git a/src/popup/components/ui/Code.tsx b/src/popup/components/ui/Code.tsx index 931e0b39..71998828 100644 --- a/src/popup/components/ui/Code.tsx +++ b/src/popup/components/ui/Code.tsx @@ -1,10 +1,10 @@ -import React from 'react' -import { Button } from './Button' -import { CheckIcon, ClipboardIcon } from '../Icons' -import { cn } from '@/shared/helpers' +import React from 'react'; +import { Button } from './Button'; +import { CheckIcon, ClipboardIcon } from '../Icons'; +import { cn } from '@/shared/helpers'; interface CodeProps extends React.HTMLAttributes { - value: string + value: string; } export const Code = ({ value, className, ...props }: CodeProps) => { @@ -12,7 +12,7 @@ export const Code = ({ value, className, ...props }: CodeProps) => {
@@ -21,23 +21,23 @@ export const Code = ({ value, className, ...props }: CodeProps) => {
- ) -} + ); +}; interface CopyButtonProps extends React.HTMLAttributes { - value: string + value: string; } const CopyButton = ({ value, ...props }: CopyButtonProps) => { - const [hasCopied, setHasCopied] = React.useState(false) + const [hasCopied, setHasCopied] = React.useState(false); React.useEffect(() => { if (hasCopied === true) { setTimeout(() => { - setHasCopied(false) - }, 2000) + setHasCopied(false); + }, 2000); } - }, [hasCopied]) + }, [hasCopied]); return ( - ) -} + ); +}; diff --git a/src/popup/components/ui/Input.tsx b/src/popup/components/ui/Input.tsx index 6d9cf837..ebbe9786 100644 --- a/src/popup/components/ui/Input.tsx +++ b/src/popup/components/ui/Input.tsx @@ -1,61 +1,75 @@ -import { type VariantProps, cva } from 'class-variance-authority' -import React, { forwardRef } from 'react' -import { cn } from '@/shared/helpers' -import { Label } from '@/popup/components/ui/Label' +import { type VariantProps, cva } from 'class-variance-authority'; +import React, { forwardRef } from 'react'; +import { cn } from '@/shared/helpers'; +import { Label } from '@/popup/components/ui/Label'; const inputVariants = cva( [ 'h-14 w-full rounded-xl border border-2 px-4 text-base text-medium', 'focus:border-focus focus:outline-none', - 'placeholder-disabled' + 'placeholder:text-disabled', ], { variants: { variant: { - default: 'border-base' + default: 'border-base', }, disabled: { - true: 'border-transparent bg-disabled' - } + true: 'border-transparent bg-disabled', + }, + readOnly: { + true: 'border-transparent bg-disabled', + }, }, defaultVariants: { - variant: 'default' - } - } -) + variant: 'default', + }, + }, +); export interface InputProps extends VariantProps, React.InputHTMLAttributes { - errorMessage?: string - disabled?: boolean - addOn?: React.ReactNode - label?: React.ReactNode - description?: React.ReactNode + errorMessage?: string; + disabled?: boolean; + readOnly?: boolean; + addOn?: React.ReactNode; + addOnPosition?: 'left' | 'right'; + label?: React.ReactNode; + description?: React.ReactNode; } export const Input = forwardRef(function Input( { type = 'text', addOn, + addOnPosition = 'left', label, description, errorMessage, disabled, className, + id, ...props }, - ref + ref, ) { - const id = React.useId() + const randomId = React.useId(); + id ||= randomId; // cannot call useId conditionally, but use randomId only if default not provided + return (
{label ? : null} {description ?

{description}

: null}
{addOn ? ( -
+
{addOn}
) : null} @@ -65,9 +79,9 @@ export const Input = forwardRef(function Input( type={type} className={cn( inputVariants({ disabled }), - addOn && 'pl-10', + addOn && (addOnPosition === 'left' ? 'pl-10' : 'pr-10'), errorMessage && 'border-error', - className + className, )} disabled={disabled ?? false} aria-disabled={disabled ?? false} @@ -80,5 +94,5 @@ export const Input = forwardRef(function Input(

{errorMessage}

)}
- ) -}) + ); +}); diff --git a/src/popup/components/ui/Label.tsx b/src/popup/components/ui/Label.tsx index 40502dc2..b17df2f3 100644 --- a/src/popup/components/ui/Label.tsx +++ b/src/popup/components/ui/Label.tsx @@ -1,25 +1,25 @@ -import { type VariantProps, cva } from 'class-variance-authority' -import React, { forwardRef } from 'react' +import { type VariantProps, cva } from 'class-variance-authority'; +import React, { forwardRef } from 'react'; -import { cn } from '@/shared/helpers' +import { cn } from '@/shared/helpers'; const labelVariants = cva( - 'flex items-center px-2 font-medium leading-6 text-medium' -) + 'flex items-center px-2 font-medium leading-6 text-medium', +); export interface LabelProps extends VariantProps, React.LabelHTMLAttributes { - children: React.ReactNode + children: React.ReactNode; } export const Label = forwardRef(function Label( { className, children, ...props }, - ref + ref, ) { return ( - ) -}) + ); +}); diff --git a/src/popup/components/ui/RadioGroup.tsx b/src/popup/components/ui/RadioGroup.tsx index 09e4bc23..c3ff2a51 100644 --- a/src/popup/components/ui/RadioGroup.tsx +++ b/src/popup/components/ui/RadioGroup.tsx @@ -1,17 +1,17 @@ -import { type VariantProps, cva } from 'class-variance-authority' -import React, { useEffect, useMemo, useState } from 'react' +import { type VariantProps, cva } from 'class-variance-authority'; +import React, { useEffect, useMemo, useState } from 'react'; -import { cn } from '@/shared/helpers' +import { cn } from '@/shared/helpers'; export interface RadioProps { - checked?: boolean - label?: string - value: string - name: string - id?: string - disabled?: boolean - onChange?: any - noSelected?: boolean + checked?: boolean; + label?: string; + value: string; + name: string; + id?: string; + disabled?: boolean; + onChange?: any; + noSelected?: boolean; } export const Radio = ({ @@ -22,14 +22,14 @@ export const Radio = ({ disabled, onChange, checked, - noSelected + noSelected, }: RadioProps): JSX.Element => { - const inputId = id || `id-${name}-${value}` - const divId = `div-${inputId}` + const inputId = id || `id-${name}-${value}`; + const divId = `div-${inputId}`; useEffect(() => { - if (checked) document.getElementById(divId)?.focus() - }, [checked, divId]) + if (checked) document.getElementById(divId)?.focus(); + }, [checked, divId]); return (
- ) -} + ); +}; const radioGroupVariants = cva(['flex gap-3'], { variants: { variant: { default: 'flex-col', - inline: 'flex-row' + inline: 'flex-row', }, fullWidth: { - true: 'w-full' - } + true: 'w-full', + }, }, defaultVariants: { - variant: 'default' - } -}) + variant: 'default', + }, +}); export interface RadioGroupProps extends VariantProps, React.InputHTMLAttributes { - disabled?: boolean - items: Omit[] - name: string - handleChange?: (value: string) => void + disabled?: boolean; + items: Omit[]; + name: string; + handleChange?: (value: string) => void; } export const RadioGroup = ({ @@ -97,27 +97,27 @@ export const RadioGroup = ({ disabled, className, handleChange, - value + value, }: RadioGroupProps) => { const checkedItem = useMemo( () => items.findIndex((item) => item.checked || item.value === value), - [items, value] - ) - const [selected, setSelected] = useState(checkedItem) + [items, value], + ); + const [selected, setSelected] = useState(checkedItem); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.code === 'ArrowRight' || event.code === 'ArrowDown') { - event.preventDefault() + event.preventDefault(); - const nextIndex = (selected >= 0 ? selected + 1 : 1) % items.length - setSelected(nextIndex) + const nextIndex = (selected >= 0 ? selected + 1 : 1) % items.length; + setSelected(nextIndex); } else if (event.code === 'ArrowLeft' || event.code === 'ArrowUp') { - event.preventDefault() + event.preventDefault(); - const prevIndex = selected > 0 ? selected - 1 : items.length - 1 - setSelected(prevIndex) + const prevIndex = selected > 0 ? selected - 1 : items.length - 1; + setSelected(prevIndex); } - } + }; useEffect(() => { const handleKeyPress = (event: KeyboardEvent) => { @@ -125,15 +125,15 @@ export const RadioGroup = ({ selected === -1 && (event.code === 'Enter' || event.code === 'Space') ) { - setSelected(0) + setSelected(0); } - } + }; - document.addEventListener('keypress', handleKeyPress) + document.addEventListener('keypress', handleKeyPress); return () => { - document.removeEventListener('keypress', handleKeyPress) - } - }, [selected]) + document.removeEventListener('keypress', handleKeyPress); + }; + }, [selected]); return (
{ - setSelected(index) - if (handleChange) handleChange(item.value) + setSelected(index); + if (handleChange) handleChange(item.value); }} /> ))}
- ) -} + ); +}; diff --git a/src/popup/components/ui/Slider.tsx b/src/popup/components/ui/Slider.tsx index e680b5f8..da729c6d 100644 --- a/src/popup/components/ui/Slider.tsx +++ b/src/popup/components/ui/Slider.tsx @@ -1,16 +1,16 @@ -import React, { forwardRef } from 'react' +import React, { forwardRef } from 'react'; -import { cn } from '@/shared/helpers' +import { cn } from '@/shared/helpers'; export interface SliderProps extends React.InputHTMLAttributes { - errorMessage?: string - disabled?: boolean - icon?: React.ReactNode - min?: number - max?: number - value?: number - onChange?: (_event: React.ChangeEvent) => void + errorMessage?: string; + disabled?: boolean; + icon?: React.ReactNode; + min?: number; + max?: number; + value?: number; + onChange?: (_event: React.ChangeEvent) => void; } const sliderClasses = ` @@ -27,7 +27,7 @@ const sliderClasses = ` [&::-webkit-slider-thumb]:disabled:bg-disabled-strong w-full h-1 bg-disabled-strong rounded-lg appearance-none cursor-pointer dark:bg-disabled-strong -` +`; export const Slider = forwardRef(function Slider( { @@ -38,7 +38,7 @@ export const Slider = forwardRef(function Slider( disabled, ...props }, - ref + ref, ) { return (
@@ -61,5 +61,5 @@ export const Slider = forwardRef(function Slider(

{errorMessage}

)}
- ) -}) + ); +}); diff --git a/src/popup/components/ui/Switch.tsx b/src/popup/components/ui/Switch.tsx index b7eac3b8..24146770 100644 --- a/src/popup/components/ui/Switch.tsx +++ b/src/popup/components/ui/Switch.tsx @@ -1,7 +1,7 @@ -import { type VariantProps, cva } from 'class-variance-authority' -import React, { forwardRef } from 'react' +import { type VariantProps, cva } from 'class-variance-authority'; +import React, { forwardRef } from 'react'; -import { cn } from '@/shared/helpers' +import { cn } from '@/shared/helpers'; const switchVariants = cva( [ @@ -10,7 +10,7 @@ const switchVariants = cva( 'before:left-[4px] before:top-1/2 before:-translate-y-1/2 before:transform', 'before:transition-all before:duration-300 before:ease-in-out', 'peer-checked:bg-switch-base peer-checked:before:left-[18px]', - 'peer-focus:outline peer-focus:outline-2 peer-focus:outline-blue-500' + 'peer-focus:outline peer-focus:outline-2 peer-focus:outline-blue-500', ], { @@ -19,27 +19,27 @@ const switchVariants = cva( default: 'h-[26px] w-[42px] before:h-5 before:w-5', small: [ 'h-[22px] w-9 before:left-[3px] before:h-4 before:w-4', - 'peer-checked:before:left-4' - ] - } + 'peer-checked:before:left-4', + ], + }, }, defaultVariants: { - size: 'default' - } - } -) + size: 'default', + }, + }, +); export interface SwitchProps extends VariantProps, React.HTMLAttributes { - checked?: boolean - label?: string - onChange?: (e: React.ChangeEvent) => void + checked?: boolean; + label?: string; + onChange?: (e: React.ChangeEvent) => void; } export const Switch = forwardRef(function Switch( { size, label, className, onChange = () => {}, ...props }, - ref + ref, ) { return (