accounts 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/core/AccessKey.d.ts +55 -0
- package/dist/core/AccessKey.d.ts.map +1 -0
- package/dist/core/AccessKey.js +69 -0
- package/dist/core/AccessKey.js.map +1 -0
- package/dist/core/Account.d.ts +91 -0
- package/dist/core/Account.d.ts.map +1 -0
- package/dist/core/Account.js +64 -0
- package/dist/core/Account.js.map +1 -0
- package/dist/core/Adapter.d.ts +187 -0
- package/dist/core/Adapter.d.ts.map +1 -0
- package/dist/core/Adapter.js +7 -0
- package/dist/core/Adapter.js.map +1 -0
- package/dist/core/Ceremony.d.ts +109 -0
- package/dist/core/Ceremony.d.ts.map +1 -0
- package/dist/core/Ceremony.js +104 -0
- package/dist/core/Ceremony.js.map +1 -0
- package/dist/core/Client.d.ts +16 -0
- package/dist/core/Client.d.ts.map +1 -0
- package/dist/core/Client.js +18 -0
- package/dist/core/Client.js.map +1 -0
- package/dist/core/Dialog.d.ts +52 -0
- package/dist/core/Dialog.d.ts.map +1 -0
- package/dist/core/Dialog.js +342 -0
- package/dist/core/Dialog.js.map +1 -0
- package/dist/core/Expiry.d.ts +15 -0
- package/dist/core/Expiry.d.ts.map +1 -0
- package/dist/core/Expiry.js +29 -0
- package/dist/core/Expiry.js.map +1 -0
- package/dist/core/Messenger.d.ts +86 -0
- package/dist/core/Messenger.d.ts.map +1 -0
- package/dist/core/Messenger.js +127 -0
- package/dist/core/Messenger.js.map +1 -0
- package/dist/core/Provider.d.ts +69 -0
- package/dist/core/Provider.d.ts.map +1 -0
- package/dist/core/Provider.js +401 -0
- package/dist/core/Provider.js.map +1 -0
- package/dist/core/Remote.d.ts +114 -0
- package/dist/core/Remote.d.ts.map +1 -0
- package/dist/core/Remote.js +116 -0
- package/dist/core/Remote.js.map +1 -0
- package/dist/core/Schema.d.ts +805 -0
- package/dist/core/Schema.d.ts.map +1 -0
- package/dist/core/Schema.js +43 -0
- package/dist/core/Schema.js.map +1 -0
- package/dist/core/Storage.d.ts +42 -0
- package/dist/core/Storage.d.ts.map +1 -0
- package/dist/core/Storage.js +173 -0
- package/dist/core/Storage.js.map +1 -0
- package/dist/core/Store.d.ts +58 -0
- package/dist/core/Store.d.ts.map +1 -0
- package/dist/core/Store.js +58 -0
- package/dist/core/Store.js.map +1 -0
- package/dist/core/adapters/dangerous_secp256k1.d.ts +30 -0
- package/dist/core/adapters/dangerous_secp256k1.d.ts.map +1 -0
- package/dist/core/adapters/dangerous_secp256k1.js +39 -0
- package/dist/core/adapters/dangerous_secp256k1.js.map +1 -0
- package/dist/core/adapters/dialog.d.ts +31 -0
- package/dist/core/adapters/dialog.d.ts.map +1 -0
- package/dist/core/adapters/dialog.js +306 -0
- package/dist/core/adapters/dialog.js.map +1 -0
- package/dist/core/adapters/local.d.ts +33 -0
- package/dist/core/adapters/local.d.ts.map +1 -0
- package/dist/core/adapters/local.js +227 -0
- package/dist/core/adapters/local.js.map +1 -0
- package/dist/core/adapters/webAuthn.d.ts +36 -0
- package/dist/core/adapters/webAuthn.d.ts.map +1 -0
- package/dist/core/adapters/webAuthn.js +93 -0
- package/dist/core/adapters/webAuthn.js.map +1 -0
- package/dist/core/internal/withDedupe.d.ts +12 -0
- package/dist/core/internal/withDedupe.d.ts.map +1 -0
- package/dist/core/internal/withDedupe.js +12 -0
- package/dist/core/internal/withDedupe.js.map +1 -0
- package/dist/core/zod/request.d.ts +31 -0
- package/dist/core/zod/request.d.ts.map +1 -0
- package/dist/core/zod/request.js +41 -0
- package/dist/core/zod/request.js.map +1 -0
- package/dist/core/zod/rpc.d.ts +603 -0
- package/dist/core/zod/rpc.d.ts.map +1 -0
- package/dist/core/zod/rpc.js +293 -0
- package/dist/core/zod/rpc.js.map +1 -0
- package/dist/core/zod/utils.d.ts +18 -0
- package/dist/core/zod/utils.d.ts.map +1 -0
- package/dist/core/zod/utils.js +21 -0
- package/dist/core/zod/utils.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/types.d.ts +284 -0
- package/dist/internal/types.d.ts.map +1 -0
- package/dist/internal/types.js +2 -0
- package/dist/internal/types.js.map +1 -0
- package/dist/server/Handler.d.ts +257 -0
- package/dist/server/Handler.d.ts.map +1 -0
- package/dist/server/Handler.js +433 -0
- package/dist/server/Handler.js.map +1 -0
- package/dist/server/Kv.d.ts +16 -0
- package/dist/server/Kv.d.ts.map +1 -0
- package/dist/server/Kv.js +30 -0
- package/dist/server/Kv.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +3 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/internal/requestListener.d.ts +124 -0
- package/dist/server/internal/requestListener.d.ts.map +1 -0
- package/dist/server/internal/requestListener.js +173 -0
- package/dist/server/internal/requestListener.js.map +1 -0
- package/dist/wagmi/Connector.d.ts +93 -0
- package/dist/wagmi/Connector.d.ts.map +1 -0
- package/dist/wagmi/Connector.js +238 -0
- package/dist/wagmi/Connector.js.map +1 -0
- package/dist/wagmi/index.d.ts +3 -0
- package/dist/wagmi/index.d.ts.map +1 -0
- package/dist/wagmi/index.js +3 -0
- package/dist/wagmi/index.js.map +1 -0
- package/package.json +56 -2
- package/src/core/AccessKey.test.ts +257 -0
- package/src/core/AccessKey.ts +123 -0
- package/src/core/Account.test.ts +309 -0
- package/src/core/Account.ts +152 -0
- package/src/core/Adapter.ts +238 -0
- package/src/core/Ceremony.browser.test.ts +239 -0
- package/src/core/Ceremony.test.ts +151 -0
- package/src/core/Ceremony.ts +203 -0
- package/src/core/Client.ts +36 -0
- package/src/core/Dialog.browser.test.ts +309 -0
- package/src/core/Dialog.test-d.ts +19 -0
- package/src/core/Dialog.ts +442 -0
- package/src/core/Expiry.ts +34 -0
- package/src/core/Messenger.ts +206 -0
- package/src/core/Provider.browser.test.ts +774 -0
- package/src/core/Provider.connect.browser.test.ts +415 -0
- package/src/core/Provider.test-d.ts +53 -0
- package/src/core/Provider.test.ts +1566 -0
- package/src/core/Provider.ts +559 -0
- package/src/core/Remote.ts +262 -0
- package/src/core/Schema.test-d.ts +211 -0
- package/src/core/Schema.ts +143 -0
- package/src/core/Storage.ts +213 -0
- package/src/core/Store.test.ts +287 -0
- package/src/core/Store.ts +129 -0
- package/src/core/adapters/dangerous_secp256k1.ts +53 -0
- package/src/core/adapters/dialog.ts +379 -0
- package/src/core/adapters/local.test.ts +97 -0
- package/src/core/adapters/local.ts +277 -0
- package/src/core/adapters/webAuthn.ts +129 -0
- package/src/core/internal/withDedupe.test.ts +116 -0
- package/src/core/internal/withDedupe.ts +20 -0
- package/src/core/mppx.test.ts +83 -0
- package/src/core/zod/request.test.ts +121 -0
- package/src/core/zod/request.ts +70 -0
- package/src/core/zod/rpc.ts +374 -0
- package/src/core/zod/utils.test.ts +69 -0
- package/src/core/zod/utils.ts +40 -0
- package/src/index.ts +14 -0
- package/src/internal/types.ts +378 -0
- package/src/server/Handler.test.ts +1014 -0
- package/src/server/Handler.ts +605 -0
- package/src/server/Kv.ts +46 -0
- package/src/server/index.ts +2 -0
- package/src/server/internal/requestListener.ts +273 -0
- package/src/tsconfig.json +9 -0
- package/src/wagmi/Connector.ts +287 -0
- package/src/wagmi/index.ts +2 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expectTypeOf, test } from 'vp/test'
|
|
2
|
+
|
|
3
|
+
import type * as Dialog from './Dialog.js'
|
|
4
|
+
import type * as Store from './Store.js'
|
|
5
|
+
|
|
6
|
+
describe('Dialog', () => {
|
|
7
|
+
test('has name and setup returning open, close, destroy, syncRequests', () => {
|
|
8
|
+
expectTypeOf<Dialog.SetupFn.Parameters>().toEqualTypeOf<{
|
|
9
|
+
host: string
|
|
10
|
+
store: Store.Store
|
|
11
|
+
}>()
|
|
12
|
+
expectTypeOf<Dialog.Instance>().toMatchTypeOf<{
|
|
13
|
+
open: () => void
|
|
14
|
+
close: () => void
|
|
15
|
+
destroy: () => void
|
|
16
|
+
syncRequests: (requests: readonly Store.QueuedRequest[]) => Promise<void>
|
|
17
|
+
}>()
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import * as Messenger from './Messenger.js'
|
|
2
|
+
import type * as Store from './Store.js'
|
|
3
|
+
|
|
4
|
+
/** Dialog interface — manages the iframe/popup lifecycle for cross-origin auth. */
|
|
5
|
+
export type Dialog = SetupFn & Meta
|
|
6
|
+
|
|
7
|
+
/** Static metadata attached to a dialog function. */
|
|
8
|
+
export type Meta = {
|
|
9
|
+
/** Identifier for the dialog type (e.g. `'iframe'`, `'popup'`). */
|
|
10
|
+
name?: string | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type Instance = {
|
|
14
|
+
/** Close the dialog (hide iframe / close popup). */
|
|
15
|
+
close: () => void
|
|
16
|
+
/** Destroy the dialog (remove DOM elements, clean up). */
|
|
17
|
+
destroy: () => void
|
|
18
|
+
/** Open the dialog (show iframe / open popup). */
|
|
19
|
+
open: () => void
|
|
20
|
+
/** Sync the pending request queue to the remote auth app. */
|
|
21
|
+
syncRequests: (requests: readonly Store.QueuedRequest[]) => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The setup function a dialog must implement. */
|
|
25
|
+
export type SetupFn = (parameters: SetupFn.Parameters) => Instance
|
|
26
|
+
|
|
27
|
+
export declare namespace SetupFn {
|
|
28
|
+
type Parameters = {
|
|
29
|
+
/** URL of the Tempo Auth app. */
|
|
30
|
+
host: string
|
|
31
|
+
/** Reactive state store. */
|
|
32
|
+
store: Store.Store
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const defaultSize = { height: 440, width: 360 }
|
|
37
|
+
|
|
38
|
+
/** Creates a dialog from metadata and a setup function. */
|
|
39
|
+
export function define(meta: Meta, fn: SetupFn): Dialog {
|
|
40
|
+
const { name, ...rest } = meta
|
|
41
|
+
Object.defineProperty(fn, 'name', { value: name, configurable: true })
|
|
42
|
+
return Object.assign(fn, rest) as Dialog
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Detects Safari (which does not support WebAuthn in cross-origin iframes). */
|
|
46
|
+
export function isSafari(): boolean {
|
|
47
|
+
if (typeof navigator === 'undefined') return false
|
|
48
|
+
const ua = navigator.userAgent.toLowerCase()
|
|
49
|
+
return ua.includes('safari') && !ua.includes('chrome')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Creates an iframe dialog that embeds the auth app in a `<dialog>` element. */
|
|
53
|
+
export function iframe(): Dialog {
|
|
54
|
+
if (typeof window === 'undefined') return noop()
|
|
55
|
+
|
|
56
|
+
return define({ name: 'iframe' }, (parameters) => {
|
|
57
|
+
const { host, store } = parameters
|
|
58
|
+
|
|
59
|
+
const fallback = popup()(parameters)
|
|
60
|
+
|
|
61
|
+
let open = false
|
|
62
|
+
|
|
63
|
+
const referrer = getReferrer()
|
|
64
|
+
|
|
65
|
+
const hostUrl = new URL(host)
|
|
66
|
+
hostUrl.searchParams.set('chainId', String(store.getState().chainId))
|
|
67
|
+
hostUrl.searchParams.set('mode', 'iframe')
|
|
68
|
+
if (referrer.icon) {
|
|
69
|
+
if (typeof referrer.icon === 'string') hostUrl.searchParams.set('icon', referrer.icon)
|
|
70
|
+
else {
|
|
71
|
+
hostUrl.searchParams.set('icon', referrer.icon.light)
|
|
72
|
+
hostUrl.searchParams.set('iconDark', referrer.icon.dark)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const root = document.createElement('dialog')
|
|
77
|
+
root.dataset.tempoWallet = ''
|
|
78
|
+
|
|
79
|
+
root.setAttribute('role', 'dialog')
|
|
80
|
+
root.setAttribute('aria-closed', 'true')
|
|
81
|
+
root.setAttribute('aria-label', 'Tempo Auth')
|
|
82
|
+
root.setAttribute('hidden', 'until-found')
|
|
83
|
+
|
|
84
|
+
Object.assign(root.style, {
|
|
85
|
+
background: 'transparent',
|
|
86
|
+
border: '0',
|
|
87
|
+
outline: '0',
|
|
88
|
+
padding: '0',
|
|
89
|
+
position: 'fixed',
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
document.body.appendChild(root)
|
|
93
|
+
|
|
94
|
+
const frame = document.createElement('iframe')
|
|
95
|
+
frame.dataset.testid = 'tempo-wallet'
|
|
96
|
+
frame.setAttribute(
|
|
97
|
+
'allow',
|
|
98
|
+
[
|
|
99
|
+
`publickey-credentials-get ${hostUrl.origin}`,
|
|
100
|
+
`publickey-credentials-create ${hostUrl.origin}`,
|
|
101
|
+
'clipboard-write',
|
|
102
|
+
].join('; '),
|
|
103
|
+
)
|
|
104
|
+
frame.setAttribute('allowtransparency', 'true')
|
|
105
|
+
frame.setAttribute('tabindex', '0')
|
|
106
|
+
frame.setAttribute('title', 'Tempo Auth')
|
|
107
|
+
frame.src = hostUrl.toString()
|
|
108
|
+
|
|
109
|
+
Object.assign(frame.style, {
|
|
110
|
+
backgroundColor: 'transparent',
|
|
111
|
+
border: '0',
|
|
112
|
+
colorScheme: 'light dark',
|
|
113
|
+
height: '100%',
|
|
114
|
+
left: '0',
|
|
115
|
+
position: 'fixed',
|
|
116
|
+
top: '0',
|
|
117
|
+
width: '100%',
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const style = document.createElement('style')
|
|
121
|
+
style.innerHTML = `
|
|
122
|
+
dialog[data-tempo-wallet]::backdrop {
|
|
123
|
+
background: transparent!important;
|
|
124
|
+
}
|
|
125
|
+
`
|
|
126
|
+
|
|
127
|
+
root.appendChild(style)
|
|
128
|
+
root.appendChild(frame)
|
|
129
|
+
|
|
130
|
+
const messenger = Messenger.bridge({
|
|
131
|
+
from: Messenger.fromWindow(window, { targetOrigin: hostUrl.origin }),
|
|
132
|
+
to: Messenger.fromWindow(frame.contentWindow!, {
|
|
133
|
+
targetOrigin: hostUrl.origin,
|
|
134
|
+
}),
|
|
135
|
+
waitForReady: true,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
messenger.on('rpc-response', (response) => handleResponse(store, response))
|
|
139
|
+
|
|
140
|
+
let savedOverflow = ''
|
|
141
|
+
let opener: HTMLElement | null = null
|
|
142
|
+
|
|
143
|
+
const onBlur = () => handleBlur(store)
|
|
144
|
+
|
|
145
|
+
// 1Password extension adds `inert` attribute to `dialog` rendering it unusable.
|
|
146
|
+
const inertObserver = new MutationObserver((mutations) => {
|
|
147
|
+
for (const mutation of mutations) {
|
|
148
|
+
if (mutation.type !== 'attributes') continue
|
|
149
|
+
if (mutation.attributeName !== 'inert') continue
|
|
150
|
+
root.removeAttribute('inert')
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
inertObserver.observe(root, { attributeOldValue: true, attributes: true })
|
|
154
|
+
|
|
155
|
+
// dialog/page interactivity (no visibility change)
|
|
156
|
+
let dialogActive = false
|
|
157
|
+
const activatePage = () => {
|
|
158
|
+
if (!dialogActive) return
|
|
159
|
+
dialogActive = false
|
|
160
|
+
|
|
161
|
+
root.removeEventListener('cancel', onBlur)
|
|
162
|
+
root.removeEventListener('click', onBlur)
|
|
163
|
+
root.style.pointerEvents = 'none'
|
|
164
|
+
opener?.focus()
|
|
165
|
+
opener = null
|
|
166
|
+
|
|
167
|
+
document.body.style.overflow = savedOverflow
|
|
168
|
+
}
|
|
169
|
+
const activateDialog = () => {
|
|
170
|
+
if (dialogActive) return
|
|
171
|
+
dialogActive = true
|
|
172
|
+
|
|
173
|
+
root.addEventListener('cancel', onBlur)
|
|
174
|
+
root.addEventListener('click', onBlur)
|
|
175
|
+
frame.focus()
|
|
176
|
+
root.style.pointerEvents = 'auto'
|
|
177
|
+
|
|
178
|
+
savedOverflow = document.body.style.overflow
|
|
179
|
+
document.body.style.overflow = 'hidden'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// dialog visibility
|
|
183
|
+
let visible = false
|
|
184
|
+
const showDialog = () => {
|
|
185
|
+
if (visible) return
|
|
186
|
+
visible = true
|
|
187
|
+
|
|
188
|
+
if (document.activeElement instanceof HTMLElement) opener = document.activeElement
|
|
189
|
+
|
|
190
|
+
root.removeAttribute('hidden')
|
|
191
|
+
root.removeAttribute('aria-closed')
|
|
192
|
+
root.showModal()
|
|
193
|
+
}
|
|
194
|
+
const hideDialog = () => {
|
|
195
|
+
if (!visible) return
|
|
196
|
+
visible = false
|
|
197
|
+
root.setAttribute('hidden', 'true')
|
|
198
|
+
root.setAttribute('aria-closed', 'true')
|
|
199
|
+
root.close()
|
|
200
|
+
|
|
201
|
+
// 1Password extension sometimes adds `inert` to dialog siblings
|
|
202
|
+
// and does not clean up when dialog closes.
|
|
203
|
+
for (const sibling of root.parentNode ? Array.from(root.parentNode.children) : []) {
|
|
204
|
+
if (sibling === root) continue
|
|
205
|
+
if (!sibling.hasAttribute('inert')) continue
|
|
206
|
+
sibling.removeAttribute('inert')
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
close() {
|
|
212
|
+
fallback.close()
|
|
213
|
+
open = false
|
|
214
|
+
|
|
215
|
+
hideDialog()
|
|
216
|
+
activatePage()
|
|
217
|
+
},
|
|
218
|
+
destroy() {
|
|
219
|
+
fallback.close()
|
|
220
|
+
open = false
|
|
221
|
+
|
|
222
|
+
activatePage()
|
|
223
|
+
hideDialog()
|
|
224
|
+
|
|
225
|
+
fallback.destroy()
|
|
226
|
+
messenger.destroy()
|
|
227
|
+
root.remove()
|
|
228
|
+
inertObserver.disconnect()
|
|
229
|
+
},
|
|
230
|
+
open() {
|
|
231
|
+
if (open) return
|
|
232
|
+
open = true
|
|
233
|
+
|
|
234
|
+
showDialog()
|
|
235
|
+
activateDialog()
|
|
236
|
+
},
|
|
237
|
+
async syncRequests(requests) {
|
|
238
|
+
// Safari does not support WebAuthn credential creation in iframes.
|
|
239
|
+
// Fall back to popup dialog.
|
|
240
|
+
const unsupported =
|
|
241
|
+
isSafari() &&
|
|
242
|
+
requests.some((x) => ['wallet_connect', 'eth_requestAccounts'].includes(x.request.method))
|
|
243
|
+
|
|
244
|
+
if (unsupported) {
|
|
245
|
+
fallback.syncRequests(requests)
|
|
246
|
+
} else {
|
|
247
|
+
const requiresConfirm = requests.some((x) => x.status === 'pending')
|
|
248
|
+
if (!open && requiresConfirm) this.open()
|
|
249
|
+
messenger.send('rpc-requests', {
|
|
250
|
+
account: getAccount(store),
|
|
251
|
+
chainId: store.getState().chainId,
|
|
252
|
+
requests,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Opens the auth app in a new browser window. */
|
|
261
|
+
export function popup(options: popup.Options = {}): Dialog {
|
|
262
|
+
if (typeof window === 'undefined') return noop()
|
|
263
|
+
|
|
264
|
+
const { size = defaultSize } = options
|
|
265
|
+
|
|
266
|
+
return define({ name: 'popup' }, (parameters) => {
|
|
267
|
+
const { host, store } = parameters
|
|
268
|
+
|
|
269
|
+
let win: Window | null = null
|
|
270
|
+
|
|
271
|
+
function onBlur() {
|
|
272
|
+
if (win) handleBlur(store)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const offDetectClosed = (() => {
|
|
276
|
+
const timer = setInterval(() => {
|
|
277
|
+
if (win?.closed) handleBlur(store)
|
|
278
|
+
}, 100)
|
|
279
|
+
return () => clearInterval(timer)
|
|
280
|
+
})()
|
|
281
|
+
|
|
282
|
+
let messenger: Messenger.Bridge | undefined
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
close() {
|
|
286
|
+
if (!win) return
|
|
287
|
+
win.close()
|
|
288
|
+
win = null
|
|
289
|
+
},
|
|
290
|
+
destroy() {
|
|
291
|
+
this.close()
|
|
292
|
+
window.removeEventListener('focus', onBlur)
|
|
293
|
+
messenger?.destroy()
|
|
294
|
+
offDetectClosed()
|
|
295
|
+
},
|
|
296
|
+
open() {
|
|
297
|
+
const referrer = getReferrer()
|
|
298
|
+
|
|
299
|
+
const hostUrl = new URL(host)
|
|
300
|
+
hostUrl.searchParams.set('chainId', String(store.getState().chainId))
|
|
301
|
+
hostUrl.searchParams.set('mode', 'popup')
|
|
302
|
+
if (referrer.icon) {
|
|
303
|
+
if (typeof referrer.icon === 'string') hostUrl.searchParams.set('icon', referrer.icon)
|
|
304
|
+
else {
|
|
305
|
+
hostUrl.searchParams.set('icon', referrer.icon.light)
|
|
306
|
+
hostUrl.searchParams.set('iconDark', referrer.icon.dark)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const left = (window.innerWidth - size.width) / 2 + window.screenX
|
|
311
|
+
const top = window.screenY + 100
|
|
312
|
+
|
|
313
|
+
win = window.open(
|
|
314
|
+
hostUrl.toString(),
|
|
315
|
+
'_blank',
|
|
316
|
+
`width=${size.width},height=${size.height},left=${left},top=${top}`,
|
|
317
|
+
)
|
|
318
|
+
if (!win) throw new Error('Failed to open popup')
|
|
319
|
+
|
|
320
|
+
messenger = Messenger.bridge({
|
|
321
|
+
from: Messenger.fromWindow(window, { targetOrigin: hostUrl.origin }),
|
|
322
|
+
to: Messenger.fromWindow(win, { targetOrigin: hostUrl.origin }),
|
|
323
|
+
waitForReady: true,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
messenger.on('rpc-response', (response) => handleResponse(store, response))
|
|
327
|
+
|
|
328
|
+
window.removeEventListener('focus', onBlur)
|
|
329
|
+
window.addEventListener('focus', onBlur)
|
|
330
|
+
},
|
|
331
|
+
async syncRequests(requests) {
|
|
332
|
+
const requiresConfirm = requests.some((x) => x.status === 'pending')
|
|
333
|
+
if (requiresConfirm) {
|
|
334
|
+
if (!win || win.closed) this.open()
|
|
335
|
+
win?.focus()
|
|
336
|
+
}
|
|
337
|
+
messenger?.send('rpc-requests', {
|
|
338
|
+
account: getAccount(store),
|
|
339
|
+
chainId: store.getState().chainId,
|
|
340
|
+
requests,
|
|
341
|
+
})
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export declare namespace popup {
|
|
348
|
+
type Options = {
|
|
349
|
+
/** Popup window dimensions. @default `{ width: 360, height: 440 }` */
|
|
350
|
+
size?: { width: number; height: number } | undefined
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Returns a no-op dialog for SSR environments. */
|
|
355
|
+
export function noop(): Dialog {
|
|
356
|
+
return define({ name: 'noop' }, () => ({
|
|
357
|
+
open() {},
|
|
358
|
+
close() {},
|
|
359
|
+
destroy() {},
|
|
360
|
+
async syncRequests() {},
|
|
361
|
+
}))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Updates the store with an RPC response from the remote auth app. */
|
|
365
|
+
function handleResponse(
|
|
366
|
+
store: Store.Store,
|
|
367
|
+
response: { id: number; result?: unknown; error?: { code: number; message: string } | undefined },
|
|
368
|
+
) {
|
|
369
|
+
store.setState((x) => ({
|
|
370
|
+
...x,
|
|
371
|
+
requestQueue: x.requestQueue.map((queued) => {
|
|
372
|
+
if (queued.request.id !== response.id) return queued
|
|
373
|
+
if (response.error)
|
|
374
|
+
return {
|
|
375
|
+
request: queued.request,
|
|
376
|
+
error: response.error,
|
|
377
|
+
status: 'error' as const,
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
request: queued.request,
|
|
381
|
+
result: response.result,
|
|
382
|
+
status: 'success' as const,
|
|
383
|
+
}
|
|
384
|
+
}),
|
|
385
|
+
}))
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Marks all pending requests as rejected (user closed the dialog). */
|
|
389
|
+
function handleBlur(store: Store.Store) {
|
|
390
|
+
store.setState((x) => ({
|
|
391
|
+
...x,
|
|
392
|
+
requestQueue: x.requestQueue.map((queued) =>
|
|
393
|
+
queued.status === 'pending'
|
|
394
|
+
? {
|
|
395
|
+
request: queued.request,
|
|
396
|
+
error: { code: 4001, message: 'User rejected the request.' },
|
|
397
|
+
status: 'error' as const,
|
|
398
|
+
}
|
|
399
|
+
: queued,
|
|
400
|
+
),
|
|
401
|
+
}))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Returns the active account from the store, or `undefined` if none. */
|
|
405
|
+
function getAccount(store: Store.Store): { address: string } | undefined {
|
|
406
|
+
const { accounts, activeAccount } = store.getState()
|
|
407
|
+
const account = accounts[activeAccount]
|
|
408
|
+
if (!account) return undefined
|
|
409
|
+
return { address: account.address }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Extracts referrer metadata from the host page.
|
|
414
|
+
* Must be called in the host page context (where `document` is accessible).
|
|
415
|
+
*/
|
|
416
|
+
function getReferrer(): getReferrer.ReturnType {
|
|
417
|
+
const icon = (() => {
|
|
418
|
+
const dark = document.querySelector(
|
|
419
|
+
'link[rel~="icon"][media="(prefers-color-scheme: dark)"]',
|
|
420
|
+
) as HTMLLinkElement | null
|
|
421
|
+
const light = (document.querySelector(
|
|
422
|
+
'link[rel~="icon"][media="(prefers-color-scheme: light)"]',
|
|
423
|
+
) ?? document.querySelector('link[rel~="icon"]')) as HTMLLinkElement | null
|
|
424
|
+
|
|
425
|
+
if (dark?.href && light?.href && dark.href !== light.href)
|
|
426
|
+
return { dark: dark.href, light: light.href }
|
|
427
|
+
|
|
428
|
+
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
429
|
+
return (isDark ? dark?.href : light?.href) ?? light?.href
|
|
430
|
+
})()
|
|
431
|
+
|
|
432
|
+
return { icon, title: document.title }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
declare namespace getReferrer {
|
|
436
|
+
type ReturnType = {
|
|
437
|
+
/** Favicon URL, or separate light/dark URLs. */
|
|
438
|
+
icon: string | { light: string; dark: string } | undefined
|
|
439
|
+
/** Document title of the host page. */
|
|
440
|
+
title: string
|
|
441
|
+
}
|
|
442
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Returns a unix timestamp (seconds) `n` days from now. */
|
|
2
|
+
export function days(n: number) {
|
|
3
|
+
return Math.floor(Date.now() / 1000) + n * 24 * 60 * 60
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Returns a unix timestamp (seconds) `n` hours from now. */
|
|
7
|
+
export function hours(n: number) {
|
|
8
|
+
return Math.floor(Date.now() / 1000) + n * 60 * 60
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Returns a unix timestamp (seconds) `n` minutes from now. */
|
|
12
|
+
export function minutes(n: number) {
|
|
13
|
+
return Math.floor(Date.now() / 1000) + n * 60
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Returns a unix timestamp (seconds) `n` months (30 days) from now. */
|
|
17
|
+
export function months(n: number) {
|
|
18
|
+
return Math.floor(Date.now() / 1000) + n * 30 * 24 * 60 * 60
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Returns a unix timestamp (seconds) `n` seconds from now. */
|
|
22
|
+
export function seconds(n: number) {
|
|
23
|
+
return Math.floor(Date.now() / 1000) + n
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Returns a unix timestamp (seconds) `n` weeks from now. */
|
|
27
|
+
export function weeks(n: number) {
|
|
28
|
+
return Math.floor(Date.now() / 1000) + n * 7 * 24 * 60 * 60
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Returns a unix timestamp (seconds) `n` years (365 days) from now. */
|
|
32
|
+
export function years(n: number) {
|
|
33
|
+
return Math.floor(Date.now() / 1000) + n * 365 * 24 * 60 * 60
|
|
34
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { RpcRequest, RpcResponse } from 'ox'
|
|
2
|
+
|
|
3
|
+
import type * as Store from './Store.js'
|
|
4
|
+
|
|
5
|
+
/** Messenger interface for cross-frame communication. */
|
|
6
|
+
export type Messenger = {
|
|
7
|
+
/** Tear down all listeners. */
|
|
8
|
+
destroy: () => void
|
|
9
|
+
/** Subscribe to a topic. Returns an unsubscribe function. */
|
|
10
|
+
on: <const topic extends Topic>(
|
|
11
|
+
topic: topic | Topic,
|
|
12
|
+
listener: (payload: Payload<topic>, event: MessageEvent) => void,
|
|
13
|
+
id?: string | undefined,
|
|
14
|
+
) => () => void
|
|
15
|
+
/** Send a message on a topic. */
|
|
16
|
+
send: <const topic extends Topic>(
|
|
17
|
+
topic: topic | Topic,
|
|
18
|
+
payload: Payload<topic>,
|
|
19
|
+
targetOrigin?: string | undefined,
|
|
20
|
+
) => Promise<{ id: string; topic: topic; payload: Payload<topic> }>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Bridge messenger that waits for a `ready` signal from the remote frame. */
|
|
24
|
+
export type Bridge = Messenger & {
|
|
25
|
+
/** Signal readiness (called by the remote frame). */
|
|
26
|
+
ready: () => void
|
|
27
|
+
/** Promise that resolves when the remote frame signals ready. */
|
|
28
|
+
waitForReady: () => Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Message schema for cross-frame communication. */
|
|
32
|
+
export type Schema = [
|
|
33
|
+
{
|
|
34
|
+
topic: 'ready'
|
|
35
|
+
payload: undefined
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
topic: 'rpc-requests'
|
|
39
|
+
payload: {
|
|
40
|
+
account: { address: string } | undefined
|
|
41
|
+
chainId: number
|
|
42
|
+
requests: readonly Store.QueuedRequest[]
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
topic: 'rpc-response'
|
|
47
|
+
payload: RpcResponse.RpcResponse & {
|
|
48
|
+
_request: RpcRequest.RpcRequest
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
topic: 'close'
|
|
53
|
+
payload: undefined
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
/** Union of all topic strings. */
|
|
58
|
+
export type Topic = Schema[number]['topic']
|
|
59
|
+
|
|
60
|
+
/** Payload for a given topic. */
|
|
61
|
+
export type Payload<topic extends Topic> = Extract<Schema[number], { topic: topic }>['payload']
|
|
62
|
+
|
|
63
|
+
/** Creates a messenger from a custom implementation. */
|
|
64
|
+
export function from(messenger: Messenger): Messenger {
|
|
65
|
+
return messenger
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Creates a messenger backed by `window.postMessage` / `addEventListener('message')`.
|
|
70
|
+
* Filters messages by `targetOrigin` when provided.
|
|
71
|
+
*/
|
|
72
|
+
export function fromWindow(w: Window, options: fromWindow.Options = {}): Messenger {
|
|
73
|
+
const { targetOrigin } = options
|
|
74
|
+
const listeners = new Map<string, (event: MessageEvent) => void>()
|
|
75
|
+
|
|
76
|
+
return from({
|
|
77
|
+
destroy() {
|
|
78
|
+
for (const listener of listeners.values()) w.removeEventListener('message', listener)
|
|
79
|
+
listeners.clear()
|
|
80
|
+
},
|
|
81
|
+
on(topic, listener, id) {
|
|
82
|
+
function onMessage(event: MessageEvent) {
|
|
83
|
+
if (event.data.topic !== topic) return
|
|
84
|
+
if (id && event.data.id !== id) return
|
|
85
|
+
if (targetOrigin && event.origin !== targetOrigin) return
|
|
86
|
+
listener(event.data.payload as never, event)
|
|
87
|
+
}
|
|
88
|
+
w.addEventListener('message', onMessage)
|
|
89
|
+
listeners.set(topic, onMessage)
|
|
90
|
+
return () => {
|
|
91
|
+
w.removeEventListener('message', onMessage)
|
|
92
|
+
listeners.delete(topic)
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
async send(topic, payload, target) {
|
|
96
|
+
const id = crypto.randomUUID()
|
|
97
|
+
w.postMessage(normalizeValue({ id, payload, topic }), target ?? targetOrigin ?? '*')
|
|
98
|
+
return { id, payload, topic } as never
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export declare namespace fromWindow {
|
|
104
|
+
type Options = {
|
|
105
|
+
/** Only accept messages from this origin. Also used as the `targetOrigin` for `postMessage`. */
|
|
106
|
+
targetOrigin?: string | undefined
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Bridges two window messengers. The bridge waits for a `ready` signal
|
|
112
|
+
* before sending messages when `waitForReady` is `true`.
|
|
113
|
+
*/
|
|
114
|
+
export function bridge(parameters: bridge.Parameters): Bridge {
|
|
115
|
+
const { from: from_, to, waitForReady = false } = parameters
|
|
116
|
+
|
|
117
|
+
let pending = false
|
|
118
|
+
|
|
119
|
+
const ready = withResolvers<void>()
|
|
120
|
+
from_.on('ready', ready.resolve)
|
|
121
|
+
|
|
122
|
+
const messenger = from({
|
|
123
|
+
destroy() {
|
|
124
|
+
from_.destroy()
|
|
125
|
+
to.destroy()
|
|
126
|
+
if (pending) ready.reject()
|
|
127
|
+
},
|
|
128
|
+
on(topic, listener, id) {
|
|
129
|
+
return from_.on(topic, listener, id)
|
|
130
|
+
},
|
|
131
|
+
async send(topic, payload) {
|
|
132
|
+
pending = true
|
|
133
|
+
if (waitForReady) await ready.promise.finally(() => (pending = false))
|
|
134
|
+
return to.send(topic, payload)
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
...messenger,
|
|
140
|
+
ready() {
|
|
141
|
+
void messenger.send('ready', undefined)
|
|
142
|
+
},
|
|
143
|
+
waitForReady() {
|
|
144
|
+
return ready.promise
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export declare namespace bridge {
|
|
150
|
+
type Parameters = {
|
|
151
|
+
/** Listens on this messenger. */
|
|
152
|
+
from: Messenger
|
|
153
|
+
/** Sends to this messenger. */
|
|
154
|
+
to: Messenger
|
|
155
|
+
/** Buffer sends until `ready` is received. */
|
|
156
|
+
waitForReady?: boolean | undefined
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Returns a no-op bridge for SSR environments. */
|
|
161
|
+
export function noop(): Bridge {
|
|
162
|
+
return {
|
|
163
|
+
destroy() {},
|
|
164
|
+
on() {
|
|
165
|
+
return () => {}
|
|
166
|
+
},
|
|
167
|
+
send() {
|
|
168
|
+
return Promise.resolve(undefined as never)
|
|
169
|
+
},
|
|
170
|
+
ready() {},
|
|
171
|
+
waitForReady() {
|
|
172
|
+
return Promise.resolve()
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function withResolvers<type>() {
|
|
178
|
+
let resolve: (value: type | PromiseLike<type>) => void = () => undefined
|
|
179
|
+
let reject: (reason?: unknown) => void = () => undefined
|
|
180
|
+
const promise = new Promise<type>((resolve_, reject_) => {
|
|
181
|
+
resolve = resolve_
|
|
182
|
+
reject = reject_
|
|
183
|
+
})
|
|
184
|
+
return { promise, reject, resolve }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Normalizes a value into a structured-clone compatible format.
|
|
189
|
+
*
|
|
190
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone
|
|
191
|
+
*/
|
|
192
|
+
function normalizeValue<type>(value: type): type {
|
|
193
|
+
if (Array.isArray(value)) return value.map(normalizeValue) as never
|
|
194
|
+
if (typeof value === 'function') return undefined as never
|
|
195
|
+
if (typeof value !== 'object' || value === null) return value
|
|
196
|
+
if (Object.getPrototypeOf(value) !== Object.prototype)
|
|
197
|
+
try {
|
|
198
|
+
return structuredClone(value)
|
|
199
|
+
} catch {
|
|
200
|
+
return undefined as never
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const normalized: Record<string, unknown> = {}
|
|
204
|
+
for (const [k, v] of Object.entries(value)) normalized[k] = normalizeValue(v)
|
|
205
|
+
return normalized as never
|
|
206
|
+
}
|