@enbox/browser 0.3.53 → 0.3.55
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/dist/browser.mjs +14 -14
- package/dist/browser.mjs.map +3 -3
- package/dist/esm/browser-connect-handler.js +2 -0
- package/dist/esm/browser-connect-handler.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/relay-connect-runner.js +23 -8
- package/dist/esm/relay-connect-runner.js.map +1 -1
- package/dist/esm/ui/connect-modal.js +34 -7
- package/dist/esm/ui/connect-modal.js.map +1 -1
- package/dist/types/browser-connect-handler.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/relay-connect-runner.d.ts +7 -1
- package/dist/types/relay-connect-runner.d.ts.map +1 -1
- package/dist/types/ui/connect-modal.d.ts +8 -1
- package/dist/types/ui/connect-modal.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/browser-connect-handler.ts +8 -3
- package/src/index.ts +13 -1
- package/src/relay-connect-runner.ts +33 -8
- package/src/ui/connect-modal.ts +61 -22
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type {
|
|
10
|
+
import type { PortableDid } from '@enbox/dids';
|
|
11
11
|
import type { ConnectHandler, ConnectResult } from '@enbox/auth';
|
|
12
|
+
import type { ConnectPermissionRequest, ConnectRequestType } from '@enbox/connect';
|
|
12
13
|
|
|
13
14
|
import { runConnectModal } from './ui/connect-modal.js';
|
|
14
15
|
|
|
@@ -175,6 +176,8 @@ export function BrowserConnectHandler(
|
|
|
175
176
|
return {
|
|
176
177
|
async requestAccess(params: {
|
|
177
178
|
permissionRequests: ConnectPermissionRequest[];
|
|
179
|
+
delegatePortableDid?: PortableDid;
|
|
180
|
+
requestType?: ConnectRequestType;
|
|
178
181
|
}): Promise<ConnectResult | undefined> {
|
|
179
182
|
return runConnectModal({
|
|
180
183
|
wallets,
|
|
@@ -182,12 +185,14 @@ export function BrowserConnectHandler(
|
|
|
182
185
|
preferredMethod,
|
|
183
186
|
rememberChoice,
|
|
184
187
|
appName,
|
|
185
|
-
appIcon
|
|
188
|
+
appIcon : appIcon ?? `${window.location.origin}/favicon.ico`,
|
|
186
189
|
connectServerUrl,
|
|
187
190
|
relayWalletPath,
|
|
188
191
|
timeout,
|
|
189
192
|
theme,
|
|
190
|
-
|
|
193
|
+
mode : params.requestType,
|
|
194
|
+
delegatePortableDid : params.delegatePortableDid,
|
|
195
|
+
permissionRequests : params.permissionRequests,
|
|
191
196
|
});
|
|
192
197
|
},
|
|
193
198
|
};
|
package/src/index.ts
CHANGED
|
@@ -49,6 +49,11 @@ export type {
|
|
|
49
49
|
export {
|
|
50
50
|
AuthManager,
|
|
51
51
|
AuthSession,
|
|
52
|
+
SESSION_EXPIRED_ERROR_CODE,
|
|
53
|
+
SESSION_REVOKED_ERROR_CODE,
|
|
54
|
+
computeConnectionStatus,
|
|
55
|
+
isSessionExpiredError,
|
|
56
|
+
isSessionInvalidError,
|
|
52
57
|
normalizeProtocolRequests,
|
|
53
58
|
WalletConnect,
|
|
54
59
|
} from '@enbox/auth/browser';
|
|
@@ -58,16 +63,23 @@ export type {
|
|
|
58
63
|
AuthEventHandler,
|
|
59
64
|
AuthManagerOptions,
|
|
60
65
|
AuthState,
|
|
66
|
+
ComputeConnectionStatusOptions,
|
|
67
|
+
ConnectionMonitorOptions,
|
|
68
|
+
ConnectionState,
|
|
69
|
+
ConnectionStatus,
|
|
70
|
+
ConnectionStatusGrant,
|
|
61
71
|
ConnectHandler,
|
|
62
72
|
ConnectOptions,
|
|
63
73
|
ConnectResult,
|
|
64
74
|
DisconnectOptions,
|
|
75
|
+
GetConnectionStatusOptions,
|
|
65
76
|
HandlerConnectOptions,
|
|
66
77
|
ImportFromPortableOptions,
|
|
67
78
|
VaultConnectOptions,
|
|
68
79
|
Permission,
|
|
69
80
|
PortableIdentity,
|
|
70
81
|
ProtocolRequest,
|
|
82
|
+
RefreshOptions,
|
|
71
83
|
RegistrationOptions,
|
|
72
84
|
StorageAdapter,
|
|
73
85
|
SyncOption,
|
|
@@ -80,7 +92,7 @@ export type {
|
|
|
80
92
|
// transports: opening/sealing envelopes and the deny token.
|
|
81
93
|
|
|
82
94
|
export { CONNECT_DENIED_TOKEN, ConnectProvider } from '@enbox/connect';
|
|
83
|
-
export type { ConnectApproval, ConnectPermissionRequest, ConnectRequest } from '@enbox/connect';
|
|
95
|
+
export type { ConnectApproval, ConnectPermissionRequest, ConnectRequest, ConnectRequestType } from '@enbox/connect';
|
|
84
96
|
|
|
85
97
|
// ─── Browser-specific exports ───────────────────────────────────
|
|
86
98
|
//
|
|
@@ -20,10 +20,12 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import type { Jwk } from '@enbox/crypto';
|
|
23
|
+
import type { PortableDid } from '@enbox/dids';
|
|
23
24
|
import type {
|
|
24
25
|
ConnectClientMetadata,
|
|
25
26
|
ConnectPermissionRequest,
|
|
26
27
|
ConnectRequest,
|
|
28
|
+
ConnectRequestType,
|
|
27
29
|
ConnectResult,
|
|
28
30
|
WalletUriHandoff,
|
|
29
31
|
} from '@enbox/connect';
|
|
@@ -35,6 +37,7 @@ import {
|
|
|
35
37
|
openResponse,
|
|
36
38
|
randomToken,
|
|
37
39
|
RelayClientTransport,
|
|
40
|
+
resolveDelegatePortableDid,
|
|
38
41
|
sealRequest,
|
|
39
42
|
} from '@enbox/connect';
|
|
40
43
|
|
|
@@ -69,6 +72,12 @@ export interface RelayConnectOptions {
|
|
|
69
72
|
/** DWN protocols and permission scopes being requested. */
|
|
70
73
|
permissionRequests: ConnectPermissionRequest[];
|
|
71
74
|
|
|
75
|
+
/** Existing delegate credentials reused by a refresh request. */
|
|
76
|
+
delegatePortableDid?: PortableDid;
|
|
77
|
+
|
|
78
|
+
/** User-facing request purpose. Absent means a normal connect. */
|
|
79
|
+
requestType?: ConnectRequestType;
|
|
80
|
+
|
|
72
81
|
/** Total milliseconds to poll the relay for a wallet response. */
|
|
73
82
|
timeoutMs?: number;
|
|
74
83
|
|
|
@@ -107,6 +116,7 @@ export interface RelayConnectOptions {
|
|
|
107
116
|
timeoutMs?: number;
|
|
108
117
|
pollIntervalMs?: number;
|
|
109
118
|
onClaimed?: () => void;
|
|
119
|
+
signal?: AbortSignal;
|
|
110
120
|
}) => Pick<RelayClientTransport, 'requestProfile' | 'deliverRequest' | 'awaitResponse' | 'requiresPin'>
|
|
111
121
|
& Partial<Pick<RelayClientTransport, 'confirmComplete'>>;
|
|
112
122
|
}
|
|
@@ -147,18 +157,24 @@ export function visibilityAwareSleep(ms: number): Promise<void> {
|
|
|
147
157
|
* delegated credentials, or `undefined` when the user denied in the wallet.
|
|
148
158
|
*/
|
|
149
159
|
export async function runRelayConnect(options: RelayConnectOptions): Promise<ConnectResult | undefined> {
|
|
160
|
+
if (options.requestType === 'refresh' && options.delegatePortableDid === undefined) {
|
|
161
|
+
throw new Error('Connect: refresh requests require an existing `delegatePortableDid`.');
|
|
162
|
+
}
|
|
163
|
+
|
|
150
164
|
const createTransport = options.createTransport
|
|
151
165
|
?? ((transportOptions): RelayClientTransport => new RelayClientTransport({
|
|
152
166
|
...transportOptions,
|
|
153
167
|
sleep: visibilityAwareSleep,
|
|
154
168
|
}));
|
|
155
169
|
|
|
170
|
+
const cancellationController = options.cancelled === undefined ? undefined : new AbortController();
|
|
156
171
|
const transport = createTransport({
|
|
157
172
|
connectServerUrl : options.connectServerUrl,
|
|
158
173
|
walletUri : options.walletUri,
|
|
159
174
|
timeoutMs : options.timeoutMs,
|
|
160
175
|
pollIntervalMs : options.pollIntervalMs,
|
|
161
176
|
onClaimed : options.onClaimed,
|
|
177
|
+
signal : cancellationController?.signal,
|
|
162
178
|
});
|
|
163
179
|
|
|
164
180
|
// Ephemeral client DID for request signing and response addressing, and a
|
|
@@ -179,6 +195,8 @@ export async function runRelayConnect(options: RelayConnectOptions): Promise<Con
|
|
|
179
195
|
appIcon : options.appIcon,
|
|
180
196
|
clientMetadata : options.clientMetadata,
|
|
181
197
|
permissionRequests : options.permissionRequests,
|
|
198
|
+
delegateDid : options.delegatePortableDid?.uri,
|
|
199
|
+
requestType : options.requestType,
|
|
182
200
|
supportedDidMethods : ['did:dht', 'did:jwk'],
|
|
183
201
|
nonce,
|
|
184
202
|
state,
|
|
@@ -197,7 +215,13 @@ export async function runRelayConnect(options: RelayConnectOptions): Promise<Con
|
|
|
197
215
|
? transport.awaitResponse()
|
|
198
216
|
: Promise.race([transport.awaitResponse(), options.cancelled]);
|
|
199
217
|
|
|
200
|
-
|
|
218
|
+
let responseCiphertext: string;
|
|
219
|
+
try {
|
|
220
|
+
responseCiphertext = await race;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
cancellationController?.abort();
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
201
225
|
if (responseCiphertext === CONNECT_DENIED_TOKEN) {
|
|
202
226
|
return undefined;
|
|
203
227
|
}
|
|
@@ -220,9 +244,10 @@ export async function runRelayConnect(options: RelayConnectOptions): Promise<Con
|
|
|
220
244
|
pin,
|
|
221
245
|
});
|
|
222
246
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
247
|
+
const delegatePortableDid = resolveDelegatePortableDid({
|
|
248
|
+
localDelegatePortableDid: options.delegatePortableDid,
|
|
249
|
+
response,
|
|
250
|
+
});
|
|
226
251
|
|
|
227
252
|
// Best-effort completion signal so the wallet can flip its pairing
|
|
228
253
|
// screen to a confirmed "connected" state instead of leaving the user
|
|
@@ -230,10 +255,10 @@ export async function runRelayConnect(options: RelayConnectOptions): Promise<Con
|
|
|
230
255
|
void transport.confirmComplete?.().catch((): undefined => undefined);
|
|
231
256
|
|
|
232
257
|
return {
|
|
233
|
-
delegatePortableDid
|
|
234
|
-
delegateGrants
|
|
235
|
-
connectedDid
|
|
236
|
-
sessionRevocations
|
|
258
|
+
delegatePortableDid,
|
|
259
|
+
delegateGrants : response.delegateGrants,
|
|
260
|
+
connectedDid : response.providerDid,
|
|
261
|
+
sessionRevocations : response.sessionRevocations,
|
|
237
262
|
};
|
|
238
263
|
} catch (error) {
|
|
239
264
|
const failure = error instanceof Error ? error : new Error(String(error));
|
package/src/ui/connect-modal.ts
CHANGED
|
@@ -37,8 +37,9 @@
|
|
|
37
37
|
* @module
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
+
import type { PortableDid } from '@enbox/dids';
|
|
40
41
|
import type { WalletOption } from '../browser-connect-handler.js';
|
|
41
|
-
import type { ConnectPermissionRequest, ConnectResult, WalletUriHandoff } from '@enbox/connect';
|
|
42
|
+
import type { ConnectPermissionRequest, ConnectRequestType, ConnectResult, WalletUriHandoff } from '@enbox/connect';
|
|
42
43
|
|
|
43
44
|
import { ConnectClient } from '@enbox/connect';
|
|
44
45
|
|
|
@@ -157,6 +158,12 @@ export interface ConnectModalOptions {
|
|
|
157
158
|
*/
|
|
158
159
|
walletUrl?: string;
|
|
159
160
|
|
|
161
|
+
/** User-facing mode. Absent means a normal connect. */
|
|
162
|
+
mode?: ConnectRequestType;
|
|
163
|
+
|
|
164
|
+
/** Existing delegate credentials reused by a refresh request. */
|
|
165
|
+
delegatePortableDid?: PortableDid;
|
|
166
|
+
|
|
160
167
|
/** Preferred method on open; overrides the remembered choice. */
|
|
161
168
|
preferredMethod?: ConnectMethod;
|
|
162
169
|
|
|
@@ -215,6 +222,8 @@ export interface ConnectModalDeps {
|
|
|
215
222
|
appName: string;
|
|
216
223
|
appIcon?: string;
|
|
217
224
|
timeoutMs?: number;
|
|
225
|
+
delegatePortableDid?: PortableDid;
|
|
226
|
+
requestType?: ConnectRequestType;
|
|
218
227
|
}) => Promise<ConnectResult | undefined>;
|
|
219
228
|
|
|
220
229
|
/** Resolves a wallet origin to its relay `connectServerUrl` (or undefined). */
|
|
@@ -246,6 +255,8 @@ async function runPopupConnect(options: {
|
|
|
246
255
|
appName: string;
|
|
247
256
|
appIcon?: string;
|
|
248
257
|
timeoutMs?: number;
|
|
258
|
+
delegatePortableDid?: PortableDid;
|
|
259
|
+
requestType?: ConnectRequestType;
|
|
249
260
|
}): Promise<ConnectResult | undefined> {
|
|
250
261
|
// The transport constructor calls `window.open` synchronously — callers
|
|
251
262
|
// must invoke this inside the user-gesture call stack.
|
|
@@ -256,10 +267,12 @@ async function runPopupConnect(options: {
|
|
|
256
267
|
const client = new ConnectClient({ transport });
|
|
257
268
|
|
|
258
269
|
return await client.connect({
|
|
259
|
-
appName
|
|
260
|
-
appIcon
|
|
261
|
-
clientMetadata
|
|
262
|
-
permissionRequests
|
|
270
|
+
appName : options.appName,
|
|
271
|
+
appIcon : options.appIcon,
|
|
272
|
+
clientMetadata : collectBrowserClientMetadata(),
|
|
273
|
+
permissionRequests : options.permissionRequests,
|
|
274
|
+
delegatePortableDid : options.delegatePortableDid,
|
|
275
|
+
requestType : options.requestType,
|
|
263
276
|
});
|
|
264
277
|
}
|
|
265
278
|
|
|
@@ -295,7 +308,7 @@ function readLastChoice(storage: ConnectModalDeps['storage']): LastChoice | unde
|
|
|
295
308
|
return undefined;
|
|
296
309
|
}
|
|
297
310
|
const parsed = JSON.parse(raw) as Partial<LastChoice>;
|
|
298
|
-
if ((parsed.method === 'phone' || parsed.method === 'browser') &&
|
|
311
|
+
if ((parsed.method === 'phone' || parsed.method === 'browser') && isValidRememberedWalletUrl(parsed.walletUrl)) {
|
|
299
312
|
return { method: parsed.method, walletUrl: parsed.walletUrl };
|
|
300
313
|
}
|
|
301
314
|
} catch { /* unreadable storage — behave as first visit */ }
|
|
@@ -345,6 +358,9 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
345
358
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
346
359
|
throw new Error('[@enbox/browser] The connect modal is only available in browser environments.');
|
|
347
360
|
}
|
|
361
|
+
if (options.mode === 'refresh' && options.delegatePortableDid === undefined) {
|
|
362
|
+
throw new Error('Connect: refresh requests require an existing `delegatePortableDid`.');
|
|
363
|
+
}
|
|
348
364
|
|
|
349
365
|
const deps: ConnectModalDeps = { ...defaultDeps(), ...options.deps };
|
|
350
366
|
const wallets = dedupeWalletsByUrl(options.wallets ?? []);
|
|
@@ -352,13 +368,18 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
352
368
|
const appName = options.appName ?? window.location.host;
|
|
353
369
|
const relayWalletPath = options.relayWalletPath ?? '/connect/app';
|
|
354
370
|
const isMobile = deps.isMobile();
|
|
371
|
+
const refreshing = options.mode === 'refresh';
|
|
355
372
|
|
|
356
373
|
const lastChoice = rememberChoice ? readLastChoice(deps.storage) : undefined;
|
|
357
374
|
|
|
358
375
|
// Wallet resolution order: explicit option → remembered → catalog head.
|
|
359
|
-
const
|
|
376
|
+
const rememberedWalletUrl = lastChoice !== undefined
|
|
377
|
+
&& (refreshing || walletInCatalog(wallets, lastChoice.walletUrl))
|
|
378
|
+
? lastChoice.walletUrl
|
|
379
|
+
: undefined;
|
|
380
|
+
const lockedWallet = options.walletUrl !== undefined || (refreshing && rememberedWalletUrl !== undefined);
|
|
360
381
|
let walletUrl = options.walletUrl
|
|
361
|
-
??
|
|
382
|
+
?? rememberedWalletUrl
|
|
362
383
|
?? wallets[0]?.url;
|
|
363
384
|
|
|
364
385
|
// Method resolution order: explicit option → remembered → phone.
|
|
@@ -380,7 +401,8 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
380
401
|
modal.className = 'modal';
|
|
381
402
|
modal.setAttribute('role', 'dialog');
|
|
382
403
|
modal.setAttribute('aria-modal', 'true');
|
|
383
|
-
|
|
404
|
+
const title = refreshing ? `Reconnect to ${appName}` : `Connect to ${appName}`;
|
|
405
|
+
modal.setAttribute('aria-label', title);
|
|
384
406
|
|
|
385
407
|
// ── Theme ──────────────────────────────────────────────────
|
|
386
408
|
// System light/dark is pure CSS (`prefers-color-scheme`), so it tracks
|
|
@@ -489,7 +511,7 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
489
511
|
const cancelModal = (): void => settle(() => reject(new Error('[@enbox/browser] Connect cancelled.')));
|
|
490
512
|
|
|
491
513
|
// ── Zones ──────────────────────────────────────────────────
|
|
492
|
-
modal.appendChild(buildHeader(
|
|
514
|
+
modal.appendChild(buildHeader(title, options.appIcon, cancelModal));
|
|
493
515
|
|
|
494
516
|
const stage = document.createElement('div');
|
|
495
517
|
stage.className = 'stage';
|
|
@@ -839,14 +861,16 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
839
861
|
try {
|
|
840
862
|
const result = await deps.runRelay({
|
|
841
863
|
connectServerUrl,
|
|
842
|
-
walletUri
|
|
864
|
+
walletUri : new URL(relayWalletPath, origin).toString(),
|
|
843
865
|
appName,
|
|
844
|
-
appIcon
|
|
845
|
-
clientMetadata
|
|
846
|
-
permissionRequests
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
866
|
+
appIcon : options.appIcon,
|
|
867
|
+
clientMetadata : collectBrowserClientMetadata(),
|
|
868
|
+
permissionRequests : options.permissionRequests,
|
|
869
|
+
delegatePortableDid : options.delegatePortableDid,
|
|
870
|
+
requestType : options.mode,
|
|
871
|
+
timeoutMs : options.timeout,
|
|
872
|
+
cancelled : session.cancelled,
|
|
873
|
+
onWalletUriReady : (handoff): void => {
|
|
850
874
|
if (session.active && !settled) {
|
|
851
875
|
renderQr(handoff);
|
|
852
876
|
scheduleRemint(handoff.expiresIn);
|
|
@@ -907,10 +931,12 @@ export function runConnectModal(options: ConnectModalOptions): Promise<ConnectRe
|
|
|
907
931
|
try {
|
|
908
932
|
deps.runPopup({
|
|
909
933
|
walletUrl,
|
|
910
|
-
permissionRequests
|
|
934
|
+
permissionRequests : options.permissionRequests,
|
|
911
935
|
appName,
|
|
912
|
-
appIcon
|
|
913
|
-
timeoutMs
|
|
936
|
+
appIcon : options.appIcon,
|
|
937
|
+
timeoutMs : options.timeout,
|
|
938
|
+
delegatePortableDid : options.delegatePortableDid,
|
|
939
|
+
requestType : options.mode,
|
|
914
940
|
})
|
|
915
941
|
.then((result) => {
|
|
916
942
|
if (settled) { return; }
|
|
@@ -1226,6 +1252,19 @@ function walletInCatalog(wallets: WalletOption[], url: string): boolean {
|
|
|
1226
1252
|
return wallets.some((wallet) => wallet.url === url);
|
|
1227
1253
|
}
|
|
1228
1254
|
|
|
1255
|
+
function isValidRememberedWalletUrl(value: unknown): value is string {
|
|
1256
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
try {
|
|
1261
|
+
const url = new URL(value);
|
|
1262
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
1263
|
+
} catch {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1229
1268
|
/** Drops catalog entries that repeat an earlier wallet's URL. */
|
|
1230
1269
|
function dedupeWalletsByUrl(wallets: WalletOption[]): WalletOption[] {
|
|
1231
1270
|
return wallets.filter((wallet, index) => wallets.findIndex((candidate) => candidate.url === wallet.url) === index);
|
|
@@ -1268,7 +1307,7 @@ function stageLinkButton(label: string, onClick: () => void): HTMLButtonElement
|
|
|
1268
1307
|
return button;
|
|
1269
1308
|
}
|
|
1270
1309
|
|
|
1271
|
-
function buildHeader(
|
|
1310
|
+
function buildHeader(titleText: string, appIcon: string | undefined, onCancel: () => void): HTMLDivElement {
|
|
1272
1311
|
const header = document.createElement('div');
|
|
1273
1312
|
header.className = 'header';
|
|
1274
1313
|
|
|
@@ -1287,7 +1326,7 @@ function buildHeader(appName: string, appIcon: string | undefined, onCancel: ()
|
|
|
1287
1326
|
}
|
|
1288
1327
|
|
|
1289
1328
|
const title = document.createElement('h2');
|
|
1290
|
-
title.textContent =
|
|
1329
|
+
title.textContent = titleText;
|
|
1291
1330
|
identity.appendChild(title);
|
|
1292
1331
|
|
|
1293
1332
|
const closeBtn = document.createElement('button');
|