UNPKG

accounts

Version:

Tempo Accounts SDK

460 lines 18.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: 600, width: 404 }; /** 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; /** 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) { cached.refs.store = parameters.store; cached.refs.fallback.destroy(); cached.refs.fallback = popup()(parameters); cached.instance.syncTheme(parameters.theme); return cached.instance; } // Different host — tear down old iframe and create fresh. cached?.instance.destroy(); const refs = { fallback: popup()(parameters), store: parameters.store, }; let open = false; const referrer = getReferrer(); const hostUrl = new URL(host); hostUrl.searchParams.set('chainId', String(refs.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 sync_displayed; let requests_displayed = []; let switchedToPopup = false; function rejectDisplayedRequests() { parameters.onReject(requests_displayed.map((x) => x.request.id)); } 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) => parameters.onResponse(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, refs.store); }); m.on('sync', ({ valid }) => { if (valid === false) refs.store.disconnect(); }); m.on('switch-mode', () => { hideDialog(); activatePage(); open = false; switchedToPopup = true; if (sync_displayed && requests_displayed.length > 0) refs.fallback.syncRequests(sync_displayed); }); 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 = () => rejectDisplayedRequests(); // 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() { refs.fallback.close(); open = false; hideDialog(); activatePage(); }, destroy() { if (cached?.instance === instance) cached = undefined; refs.fallback.close(); open = false; activatePage(); hideDialog(); refs.fallback.destroy(); messenger.destroy(); root.remove(); inertObserver.disconnect(); }, open() { if (open) return; open = true; showDialog(); activateDialog(); }, async syncRequests(sync) { const { requests } = sync; sync_displayed = sync; requests_displayed = requests; if (switchedToPopup) { refs.fallback.syncRequests(sync); 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))) { refs.fallback.syncRequests(sync); 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')); refs.fallback.syncRequests(sync); } else { const requiresConfirm = requests.some((x) => x.status === 'pending'); if (!open && requiresConfirm) this.open(); messenger.send('rpc-requests', { account: sync.account, chainId: sync.chainId, requests, }); } }, syncTheme(theme) { frame.style.colorScheme = theme?.scheme ?? 'light dark'; messenger.send('theme', theme ?? {}); }, }; cached = { host, instance, refs }; 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; let requests_displayed = []; function rejectDisplayedRequests() { parameters.onReject(requests_displayed.map((x) => x.request.id)); } const offDetectClosed = (() => { const timer = setInterval(() => { if (win?.closed) rejectDisplayedRequests(); }, 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', () => rejectDisplayedRequests()); 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.innerHeight - size.height) / 2 + window.screenY; 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) => parameters.onResponse(response)); overlay.style.display = 'flex'; }, async syncRequests(sync) { const { requests } = sync; requests_displayed = 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: sync.account, chainId: sync.chainId, requests, }); }, syncTheme() { }, }; }); } /** Returns a no-op dialog for SSR environments. */ export function noop() { return define({ name: 'noop' }, () => ({ open() { }, close() { }, destroy() { }, async syncRequests() { }, syncTheme() { }, })); } /** Sends stored account addresses to the wallet for session validation. */ function syncAccounts(messenger, store) { const { accounts } = store.getState(); if (accounts.length === 0) return; messenger.send('sync', { addresses: accounts.map((a) => a.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