accounts
Version:
Tempo Accounts SDK
460 lines • 18.8 kB
JavaScript
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