UNPKG

accounts

Version:

Tempo Accounts SDK

516 lines 20.8 kB
import * as IO from './IntersectionObserver.js'; import * as Messenger from './Messenger.js'; import * as TrustedHosts from './TrustedHosts.js'; /** Serializes theme options onto a URL's search params. */ function applyThemeParams(url, theme) { if (!theme) return; if (theme.accent) url.searchParams.set('accent', theme.accent); if (theme.radius) url.searchParams.set('radius', theme.radius); if (theme.scheme) url.searchParams.set('scheme', theme.scheme); } export const defaultSize = { height: 440, width: 360 }; /** Creates a dialog from metadata and a setup function. */ export function define(meta, fn) { const { name, ...rest } = meta; Object.defineProperty(fn, 'name', { value: name, configurable: true }); return Object.assign(fn, rest); } /** Detects an insecure context (e.g. HTTP) where iframes lack WebAuthn support. */ export function isInsecureContext() { if (typeof window === 'undefined') return false; // `http://localhost` is a secure context but WebAuthn still requires HTTPS. if (window.location.protocol === 'http:') return true; return !window.isSecureContext; } /** Detects Safari (which does not support WebAuthn in cross-origin iframes). */ export function isSafari() { if (typeof navigator === 'undefined') return false; const ua = navigator.userAgent.toLowerCase(); return ua.includes('safari') && !ua.includes('chrome'); } /** Cached iframe singleton — keyed by host, reused across setup calls. */ let cached; /** Mutable refs swapped on re-entry so the singleton always uses the latest caller's state. */ let store; let fallback; /** Previous stores kept alive so in-flight responses find their matching request. */ let previousStores = []; /** Creates an iframe dialog that embeds the auth app in a `<dialog>` element. */ export function iframe() { if (typeof window === 'undefined') return noop(); return define({ name: 'iframe' }, (parameters) => { const { host } = parameters; // Reuse existing iframe if the host matches — just swap the store/fallback refs. if (cached && cached.host === host) { const oldStore = store; store = parameters.store; // Keep the old store so in-flight responses can find their matching request. if (oldStore && oldStore !== store && !previousStores.includes(oldStore)) previousStores.push(oldStore); fallback?.destroy(); fallback = popup()(parameters); cached.instance.syncTheme(parameters.theme); return cached.instance; } // Different host — tear down old iframe and create fresh. cached?.instance.destroy(); store = parameters.store; fallback = popup()(parameters); let open = false; const referrer = getReferrer(); const hostUrl = new URL(host); hostUrl.searchParams.set('chainId', String(store.getState().chainId)); hostUrl.searchParams.set('mode', 'iframe'); if (referrer.icon) { if (typeof referrer.icon === 'string') hostUrl.searchParams.set('icon', referrer.icon); else { hostUrl.searchParams.set('icon', referrer.icon.light); hostUrl.searchParams.set('iconDark', referrer.icon.dark); } } applyThemeParams(hostUrl, parameters.theme); const root = document.createElement('dialog'); root.dataset.tempoWallet = ''; root.setAttribute('role', 'dialog'); root.setAttribute('aria-closed', 'true'); root.setAttribute('aria-label', 'Tempo Wallet'); root.setAttribute('hidden', 'until-found'); Object.assign(root.style, { background: 'transparent', border: '0', outline: '0', padding: '0', position: 'fixed', }); const frame = document.createElement('iframe'); frame.dataset.testid = 'tempo-wallet'; frame.setAttribute('allow', [ `publickey-credentials-get ${hostUrl.origin}`, `publickey-credentials-create ${hostUrl.origin}`, 'clipboard-write', 'payment', ].join('; ')); frame.setAttribute('allowtransparency', 'true'); frame.setAttribute('tabindex', '0'); frame.setAttribute('title', 'Tempo Wallet'); frame.src = hostUrl.toString(); Object.assign(frame.style, { backgroundColor: 'transparent', border: '0', colorScheme: parameters.theme?.scheme ?? 'light dark', height: '100%', left: '0', position: 'fixed', top: '0', width: '100%', }); const style = document.createElement('style'); style.innerHTML = ` dialog[data-tempo-wallet]::backdrop { background: transparent!important; } `; root.appendChild(style); root.appendChild(frame); let readyResult; let switchedToPopup = false; function createMessenger() { readyResult = undefined; const m = Messenger.bridge({ from: Messenger.fromWindow(window, { targetOrigin: hostUrl.origin }), to: Messenger.fromWindow(frame.contentWindow, { targetOrigin: hostUrl.origin, }), waitForReady: true, }); m.on('rpc-response', (response) => { const targetStore = findStoreForResponse(store, previousStores, response.id); handleResponse(targetStore, response); }); m.waitForReady().then((result) => { readyResult = result; if (result.colorScheme) frame.style.colorScheme = result.colorScheme; // Ask the wallet to verify the SDK's stored accounts are still valid. syncAccounts(m); }); m.on('sync', ({ valid }) => { if (valid === false) store?.setState({ accessKeys: [], accounts: [], activeAccount: 0 }); }); m.on('switch-mode', () => { hideDialog(); activatePage(); open = false; switchedToPopup = true; const pending = store ?.getState() .requestQueue.filter((x) => x.status === 'pending'); if (pending && pending.length > 0) fallback?.syncRequests(pending); }); return m; } document.body.appendChild(root); let messenger = createMessenger(); // Re-mount if removed (e.g. React hydration clears non-server-rendered elements). // The iframe reloads on re-append, so the messenger must be re-established. new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.removedNodes) { if (node !== root) continue; document.body.appendChild(root); messenger.destroy(); messenger = createMessenger(); return; } } }).observe(document.body, { childList: true }); let savedOverflow = ''; let opener = null; const onBlur = () => handleBlur(store); // 1Password extension adds `inert` attribute to `dialog` rendering it unusable. const inertObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type !== 'attributes') continue; if (mutation.attributeName !== 'inert') continue; root.removeAttribute('inert'); } }); inertObserver.observe(root, { attributeOldValue: true, attributes: true }); // dialog/page interactivity (no visibility change) let dialogActive = false; const activatePage = () => { if (!dialogActive) return; dialogActive = false; root.removeEventListener('cancel', onBlur); root.removeEventListener('click', onBlur); root.style.pointerEvents = 'none'; opener?.focus(); opener = null; document.body.style.overflow = savedOverflow; }; const activateDialog = () => { if (dialogActive) return; dialogActive = true; root.addEventListener('cancel', onBlur); root.addEventListener('click', onBlur); frame.focus(); root.style.pointerEvents = 'auto'; savedOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; }; // dialog visibility let visible = false; const showDialog = () => { if (visible) return; visible = true; if (document.activeElement instanceof HTMLElement) opener = document.activeElement; root.removeAttribute('hidden'); root.removeAttribute('aria-closed'); root.showModal(); }; const hideDialog = () => { if (!visible) return; visible = false; root.setAttribute('hidden', 'true'); root.setAttribute('aria-closed', 'true'); root.close(); // 1Password extension sometimes adds `inert` to dialog siblings // and does not clean up when dialog closes. for (const sibling of root.parentNode ? Array.from(root.parentNode.children) : []) { if (sibling === root) continue; if (!sibling.hasAttribute('inert')) continue; sibling.removeAttribute('inert'); } }; const instance = { close() { fallback.close(); open = false; hideDialog(); activatePage(); }, destroy() { if (cached?.instance === instance) cached = undefined; fallback?.close(); open = false; activatePage(); hideDialog(); fallback?.destroy(); messenger.destroy(); root.remove(); inertObserver.disconnect(); store = undefined; fallback = undefined; previousStores = []; }, open() { if (open) return; open = true; showDialog(); activateDialog(); }, async syncRequests(requests) { if (switchedToPopup) { fallback.syncRequests(requests); return; } const { trustedHosts } = readyResult ?? (await messenger.waitForReady()); // Safari does not support WebAuthn credential creation in iframes. if (isSafari() && requests.some((x) => ['wallet_connect', 'eth_requestAccounts'].includes(x.request.method))) { fallback.syncRequests(requests); return; } const ioSupported = IO.supported(); const hostname = window.location.hostname.replace(/^www\./, ''); const trusted = Boolean(trustedHosts && TrustedHosts.match(trustedHosts, hostname, hostUrl.hostname)); const secure = ioSupported || trusted; if (!secure) { console.warn([ `[accounts] Browser does not support IntersectionObserver v2 and "${window.location.hostname}" is not a trusted host.`, 'Falling back to popup dialog.', '', 'To enable the iframe dialog, add your hostname to the trusted hosts list.', ].join('\n')); fallback.syncRequests(requests); } else { const requiresConfirm = requests.some((x) => x.status === 'pending'); if (!open && requiresConfirm) this.open(); messenger.send('rpc-requests', { account: getAccount(store), chainId: store.getState().chainId, requests, }); } }, syncTheme(theme) { frame.style.colorScheme = theme?.scheme ?? 'light dark'; messenger.send('theme', theme ?? {}); }, }; cached = { host, instance }; return instance; }); } /** Opens the auth app in a new browser window. */ export function popup(options = {}) { if (typeof window === 'undefined') return noop(); const { size = defaultSize } = options; return define({ name: 'popup' }, (parameters) => { const { host, store } = parameters; let win = null; const offDetectClosed = (() => { const timer = setInterval(() => { if (win?.closed) handleBlur(store); }, 100); return () => clearInterval(timer); })(); let messenger; const overlay = document.createElement('div'); Object.assign(overlay.style, { alignItems: 'center', background: 'rgba(0, 0, 0, 0.5)', color: 'white', display: 'none', flexDirection: 'column', fontFamily: 'system-ui, sans-serif', fontSize: '16px', gap: '12px', inset: '0', justifyContent: 'center', position: 'fixed', zIndex: '2147483647', }); const overlayMessage = document.createElement('p'); Object.assign(overlayMessage.style, { margin: '0' }); overlayMessage.textContent = 'Continue in the popup window'; const overlayClose = document.createElement('button'); Object.assign(overlayClose.style, { background: 'none', border: 'none', color: 'white', cursor: 'pointer', font: 'inherit', padding: '0', textDecoration: 'underline', }); overlayClose.textContent = 'Close'; overlayClose.addEventListener('click', () => handleBlur(store)); overlay.appendChild(overlayMessage); overlay.appendChild(overlayClose); document.body.appendChild(overlay); return { close() { overlay.style.display = 'none'; if (!win) return; win.close(); win = null; }, destroy() { this.close(); messenger?.destroy(); offDetectClosed(); overlay.remove(); }, open() { messenger?.destroy(); win?.close(); const referrer = getReferrer(); const hostUrl = new URL(host); hostUrl.searchParams.set('chainId', String(store.getState().chainId)); hostUrl.searchParams.set('mode', 'popup'); if (referrer.icon) { if (typeof referrer.icon === 'string') hostUrl.searchParams.set('icon', referrer.icon); else { hostUrl.searchParams.set('icon', referrer.icon.light); hostUrl.searchParams.set('iconDark', referrer.icon.dark); } } applyThemeParams(hostUrl, parameters.theme); const left = (window.innerWidth - size.width) / 2 + window.screenX; const top = window.screenY + 100; win = window.open(hostUrl.toString(), '_blank', `width=${size.width},height=${size.height},left=${left},top=${top}`); if (!win) throw new Error('Failed to open popup'); messenger = Messenger.bridge({ from: Messenger.fromWindow(window, { targetOrigin: hostUrl.origin }), to: Messenger.fromWindow(win, { targetOrigin: hostUrl.origin }), waitForReady: true, }); messenger.on('rpc-response', (response) => handleResponse(store, response)); overlay.style.display = 'flex'; }, async syncRequests(requests) { const requiresConfirm = requests.some((x) => x.status === 'pending'); if (requiresConfirm) { if (!win || win.closed) this.open(); else win.focus(); } messenger?.send('rpc-requests', { account: getAccount(store), chainId: store.getState().chainId, requests, }); }, syncTheme() { }, }; }); } /** Returns a no-op dialog for SSR environments. */ export function noop() { return define({ name: 'noop' }, () => ({ open() { }, close() { }, destroy() { }, async syncRequests() { }, syncTheme() { }, })); } /** Finds the store that owns the request matching the given response id. */ function findStoreForResponse(current, previous, id) { if (current.getState().requestQueue.some((q) => q.request.id === id)) return current; for (const s of previous) { if (s.getState().requestQueue.some((q) => q.request.id === id)) return s; } return current; } /** Updates the store with an RPC response from the remote auth app. */ function handleResponse(store, response) { store.setState((x) => ({ ...x, requestQueue: x.requestQueue.map((queued) => { if (queued.request.id !== response.id) return queued; if (response.error) return { request: queued.request, error: response.error, status: 'error', }; return { request: queued.request, result: response.result, status: 'success', }; }), })); } /** Marks all pending requests as rejected (user closed the dialog). */ function handleBlur(store) { store.setState((x) => ({ ...x, requestQueue: x.requestQueue.map((queued) => queued.status === 'pending' ? { request: queued.request, error: { code: 4001, message: 'User rejected the request.' }, status: 'error', } : queued), })); } /** Sends stored account addresses to the wallet for session validation. */ function syncAccounts(messenger) { if (!store) return; const { accounts } = store.getState(); if (accounts.length === 0) return; messenger.send('sync', { addresses: accounts.map((a) => a.address) }); } /** Returns the active account from the store, or `undefined` if none. */ function getAccount(store) { const { accounts, activeAccount } = store.getState(); const account = accounts[activeAccount]; if (!account) return undefined; return { address: account.address }; } /** * Extracts referrer metadata from the host page. * Must be called in the host page context (where `document` is accessible). */ function getReferrer() { const icon = (() => { const dark = document.querySelector('link[rel~="icon"][media="(prefers-color-scheme: dark)"]'); const light = (document.querySelector('link[rel~="icon"][media="(prefers-color-scheme: light)"]') ?? document.querySelector('link[rel~="icon"]')); if (dark?.href && light?.href && dark.href !== light.href) return { dark: dark.href, light: light.href }; const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; return (isDark ? dark?.href : light?.href) ?? light?.href; })(); return { icon, title: document.title }; } //# sourceMappingURL=Dialog.js.map