@onekeyfe/hd-core 1.2.0-alpha.42 → 1.2.0-alpha.44
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/__tests__/deviceUploadNft.test.ts +87 -2
- package/__tests__/open-wallet-session.test.ts +161 -21
- package/__tests__/protocol-v2-ui-lifecycle.test.ts +35 -0
- package/__tests__/protocol-v2-unlock-policy.test.ts +110 -0
- package/__tests__/protocol-v2.test.ts +71 -13
- package/__tests__/protocolV2UiInteraction.test.ts +63 -0
- package/dist/api/protocol-v2/DeviceUploadNft.d.ts +1 -0
- package/dist/api/protocol-v2/DeviceUploadNft.d.ts.map +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/device/Device.d.ts +3 -1
- package/dist/device/Device.d.ts.map +1 -1
- package/dist/device/DeviceCommands.d.ts +4 -4
- package/dist/events/ui-request.d.ts +1 -0
- package/dist/events/ui-request.d.ts.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +174 -45
- package/dist/protocols/protocol-v2/uiInteraction.d.ts +4 -1
- package/dist/protocols/protocol-v2/uiInteraction.d.ts.map +1 -1
- package/dist/protocols/protocol-v2/unlockPolicyRunner.d.ts.map +1 -1
- package/dist/protocols/protocol-v2/walletSession.d.ts.map +1 -1
- package/dist/utils/pro2Nft.d.ts +2 -0
- package/dist/utils/pro2Nft.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/api/protocol-v2/DeviceUploadNft.ts +25 -2
- package/src/core/index.ts +18 -2
- package/src/data/messages/messages-protocol-v2.json +15 -0
- package/src/device/Device.ts +15 -5
- package/src/events/ui-request.ts +1 -0
- package/src/protocols/protocol-v2/uiInteraction.ts +20 -6
- package/src/protocols/protocol-v2/unlockPolicyRunner.ts +8 -1
- package/src/protocols/protocol-v2/walletSession.ts +84 -24
- package/src/utils/pro2Nft.ts +51 -0
|
@@ -12244,11 +12244,26 @@
|
|
|
12244
12244
|
"DeviceSessionError_Busy": 5
|
|
12245
12245
|
}
|
|
12246
12246
|
},
|
|
12247
|
+
"DeviceSessionSeedDomain": {
|
|
12248
|
+
"values": {
|
|
12249
|
+
"SeedDomain_Standard": 1,
|
|
12250
|
+
"SeedDomain_Cardano": 2
|
|
12251
|
+
}
|
|
12252
|
+
},
|
|
12247
12253
|
"DeviceSessionGet": {
|
|
12248
12254
|
"fields": {
|
|
12249
12255
|
"session_id": {
|
|
12250
12256
|
"type": "bytes",
|
|
12251
12257
|
"id": 1
|
|
12258
|
+
},
|
|
12259
|
+
"btc_test_address": {
|
|
12260
|
+
"type": "string",
|
|
12261
|
+
"id": 2
|
|
12262
|
+
},
|
|
12263
|
+
"seed_domains": {
|
|
12264
|
+
"rule": "repeated",
|
|
12265
|
+
"type": "DeviceSessionSeedDomain",
|
|
12266
|
+
"id": 3
|
|
12252
12267
|
}
|
|
12253
12268
|
}
|
|
12254
12269
|
},
|
package/src/device/Device.ts
CHANGED
|
@@ -1644,18 +1644,28 @@ export class Device extends EventEmitter {
|
|
|
1644
1644
|
});
|
|
1645
1645
|
}
|
|
1646
1646
|
|
|
1647
|
-
finishProtocolV2UiInteraction(
|
|
1647
|
+
finishProtocolV2UiInteraction(
|
|
1648
|
+
outcome?: HardwareUiInteractionMeta['outcome'],
|
|
1649
|
+
options?: { ensureMetadata?: boolean }
|
|
1650
|
+
) {
|
|
1648
1651
|
const interaction = this.protocolV2UiInteraction;
|
|
1649
|
-
if (!interaction?.
|
|
1652
|
+
if (!interaction || (!interaction.opened && !options?.ensureMetadata)) {
|
|
1650
1653
|
this.protocolV2UiInteraction = undefined;
|
|
1651
1654
|
return undefined;
|
|
1652
1655
|
}
|
|
1653
1656
|
|
|
1654
1657
|
const phaseId = `${interaction.interactionId}:phase-${Math.max(interaction.phaseCounter, 1)}`;
|
|
1655
|
-
|
|
1658
|
+
interaction.opened = true;
|
|
1659
|
+
interaction.sequence += 1;
|
|
1660
|
+
const metadata: HardwareUiInteractionMeta = {
|
|
1661
|
+
interactionId: interaction.interactionId,
|
|
1656
1662
|
phaseId,
|
|
1657
|
-
|
|
1658
|
-
|
|
1663
|
+
sequence: interaction.sequence,
|
|
1664
|
+
phase: 'processing',
|
|
1665
|
+
transition: 'finish',
|
|
1666
|
+
outcome: outcome ?? 'succeeded',
|
|
1667
|
+
protocol: 'V2',
|
|
1668
|
+
};
|
|
1659
1669
|
this.protocolV2UiInteraction = undefined;
|
|
1660
1670
|
return metadata;
|
|
1661
1671
|
}
|
package/src/events/ui-request.ts
CHANGED
|
@@ -64,6 +64,7 @@ export type HardwareUiInteractionMeta = {
|
|
|
64
64
|
transition: 'start' | 'complete' | 'finish';
|
|
65
65
|
outcome?: 'submitted' | 'succeeded' | 'failed' | 'cancelled' | 'disconnected';
|
|
66
66
|
protocol: 'V2';
|
|
67
|
+
device?: Device;
|
|
67
68
|
};
|
|
68
69
|
|
|
69
70
|
export type ProtocolV2UiEventMetadata = {
|
|
@@ -203,20 +203,34 @@ export class ProtocolV2UiInteractionCoordinator {
|
|
|
203
203
|
this.enterMethodInteraction(this.methodInteraction);
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
close(
|
|
206
|
+
close(
|
|
207
|
+
options: {
|
|
208
|
+
ensureOperationClose?: boolean;
|
|
209
|
+
protocolV2Operation?: boolean;
|
|
210
|
+
} = {}
|
|
211
|
+
): boolean {
|
|
207
212
|
if (
|
|
208
|
-
!this.device.isProtocolV2() ||
|
|
209
|
-
(!
|
|
213
|
+
(!options.protocolV2Operation && !this.device.isProtocolV2()) ||
|
|
214
|
+
(!options.ensureOperationClose &&
|
|
215
|
+
!this.opened &&
|
|
216
|
+
!this.device.hasOpenProtocolV2UiInteraction?.()) ||
|
|
210
217
|
this.closed
|
|
211
218
|
)
|
|
212
|
-
return;
|
|
219
|
+
return false;
|
|
213
220
|
this.closed = true;
|
|
214
|
-
const interaction = this.device.finishProtocolV2UiInteraction?.(
|
|
221
|
+
const interaction = this.device.finishProtocolV2UiInteraction?.('succeeded', {
|
|
222
|
+
ensureMetadata: options.ensureOperationClose,
|
|
223
|
+
});
|
|
224
|
+
const device = this.device.toMessageObject();
|
|
215
225
|
this.postMessage(
|
|
216
226
|
interaction
|
|
217
|
-
? createUiMessage(UI_REQUEST.CLOSE_UI_WINDOW,
|
|
227
|
+
? createUiMessage(UI_REQUEST.CLOSE_UI_WINDOW, {
|
|
228
|
+
...interaction,
|
|
229
|
+
...(device ? { device } : {}),
|
|
230
|
+
})
|
|
218
231
|
: createUiMessage(UI_REQUEST.CLOSE_UI_WINDOW)
|
|
219
232
|
);
|
|
233
|
+
return true;
|
|
220
234
|
}
|
|
221
235
|
|
|
222
236
|
private emit(
|
|
@@ -80,7 +80,14 @@ export async function runMethodWithUnlockPolicy<T = unknown>(
|
|
|
80
80
|
|
|
81
81
|
await afterStatusBeforeUnlock?.();
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
const isStandardWalletRequest =
|
|
84
|
+
method.unlockPolicy !== 'unlock-before-run' &&
|
|
85
|
+
method.useDevicePassphraseState &&
|
|
86
|
+
method.payload?.useEmptyPassphrase === true;
|
|
87
|
+
const standardWalletSessionOwnsUnlock =
|
|
88
|
+
isStandardWalletRequest && status.passphraseProtection === true;
|
|
89
|
+
|
|
90
|
+
if (!status.unlocked && !standardWalletSessionOwnsUnlock) {
|
|
84
91
|
const unlockInteraction: HardwareUiInteractionMeta | undefined = shouldCoordinateUi
|
|
85
92
|
? uiCoordinator.enterUnlockInteraction(method.name)
|
|
86
93
|
: undefined;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ERRORS, HardwareErrorCode } from '@onekeyfe/hd-shared';
|
|
2
|
-
import { DeviceSessionPinType } from '@onekeyfe/hd-transport';
|
|
2
|
+
import { DeviceSessionPinType, DeviceSessionSeedDomain } from '@onekeyfe/hd-transport';
|
|
3
3
|
|
|
4
4
|
import { DEVICE } from '../../events';
|
|
5
5
|
import { assertCompleteDeviceSession } from './deviceSession';
|
|
@@ -63,12 +63,36 @@ const negotiateEventlessWalletSession = async (device: Device) => {
|
|
|
63
63
|
const getDeviceSession = async (device: Device, request: DeviceSessionGet) =>
|
|
64
64
|
device.commands.typedCall('DeviceSessionGet', 'DeviceSession', request);
|
|
65
65
|
|
|
66
|
+
const buildDeviceSessionGetRequest = ({
|
|
67
|
+
sessionId,
|
|
68
|
+
expectedPassphraseState,
|
|
69
|
+
deriveCardano,
|
|
70
|
+
}: {
|
|
71
|
+
sessionId?: string;
|
|
72
|
+
expectedPassphraseState?: string;
|
|
73
|
+
deriveCardano?: boolean;
|
|
74
|
+
} = {}): DeviceSessionGet => ({
|
|
75
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
76
|
+
...(expectedPassphraseState ? { btc_test_address: expectedPassphraseState } : {}),
|
|
77
|
+
seed_domains:
|
|
78
|
+
deriveCardano === undefined
|
|
79
|
+
? []
|
|
80
|
+
: [
|
|
81
|
+
DeviceSessionSeedDomain.SeedDomain_Standard,
|
|
82
|
+
...(deriveCardano ? [DeviceSessionSeedDomain.SeedDomain_Cardano] : []),
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
66
86
|
const askDevicePassphrase = async (device: Device, requestPayload: DeviceSessionAskPassphrase) => {
|
|
67
87
|
await device.commands.typedCall('DeviceSessionAskPassphrase', 'Success', requestPayload);
|
|
68
88
|
await refreshProtocolV2DeviceStatus(device);
|
|
69
89
|
};
|
|
70
90
|
|
|
71
|
-
const selectDeviceSession = async (
|
|
91
|
+
const selectDeviceSession = async (
|
|
92
|
+
device: Device,
|
|
93
|
+
expectedPassphraseState?: string,
|
|
94
|
+
deriveCardano?: boolean
|
|
95
|
+
) => {
|
|
72
96
|
const existsAttachPinUser = device.features?.attachToPinEnabled === true;
|
|
73
97
|
const metadata = {
|
|
74
98
|
source: 'wallet-session-coordinator' as const,
|
|
@@ -129,7 +153,7 @@ const selectDeviceSession = async (device: Device, expectedPassphraseState?: str
|
|
|
129
153
|
emitUiEvent: false,
|
|
130
154
|
interaction: attachPinInteraction,
|
|
131
155
|
});
|
|
132
|
-
return getDeviceSession(device, {});
|
|
156
|
+
return getDeviceSession(device, buildDeviceSessionGetRequest({ deriveCardano }));
|
|
133
157
|
}
|
|
134
158
|
|
|
135
159
|
if (hasHostPassphrase) {
|
|
@@ -137,7 +161,7 @@ const selectDeviceSession = async (device: Device, expectedPassphraseState?: str
|
|
|
137
161
|
passphrase: hostPassphrase,
|
|
138
162
|
on_device: false,
|
|
139
163
|
});
|
|
140
|
-
return getDeviceSession(device, {});
|
|
164
|
+
return getDeviceSession(device, buildDeviceSessionGetRequest({ deriveCardano }));
|
|
141
165
|
}
|
|
142
166
|
|
|
143
167
|
device.emit(DEVICE.PASSPHRASE_ON_DEVICE, device, {
|
|
@@ -145,7 +169,7 @@ const selectDeviceSession = async (device: Device, expectedPassphraseState?: str
|
|
|
145
169
|
...(passphraseInteraction ? { interaction: passphraseInteraction } : {}),
|
|
146
170
|
});
|
|
147
171
|
await askDevicePassphrase(device, { on_device: true });
|
|
148
|
-
return getDeviceSession(device, {});
|
|
172
|
+
return getDeviceSession(device, buildDeviceSessionGetRequest({ deriveCardano }));
|
|
149
173
|
};
|
|
150
174
|
|
|
151
175
|
export async function getProtocolV2WalletSession(
|
|
@@ -191,7 +215,10 @@ export async function getProtocolV2WalletSession(
|
|
|
191
215
|
: undefined;
|
|
192
216
|
let response;
|
|
193
217
|
let resumed = false;
|
|
194
|
-
let
|
|
218
|
+
let mainPinSelected =
|
|
219
|
+
options?.onlyMainPin === true &&
|
|
220
|
+
device.features?.unlocked === true &&
|
|
221
|
+
device.features?.unlockedAttachPin === false;
|
|
195
222
|
|
|
196
223
|
const clearCurrentWalletSession = () => {
|
|
197
224
|
if (options?.onlyMainPin) {
|
|
@@ -216,14 +243,25 @@ export async function getProtocolV2WalletSession(
|
|
|
216
243
|
throw ERRORS.TypedError(HardwareErrorCode.DeviceCheckUnlockTypeError);
|
|
217
244
|
};
|
|
218
245
|
|
|
219
|
-
const
|
|
220
|
-
if (force || !
|
|
246
|
+
const selectMainPin = async (force = false) => {
|
|
247
|
+
if (force || !mainPinSelected) {
|
|
221
248
|
await device.unlockDevice(DeviceSessionPinType.Main, {
|
|
222
249
|
source: 'wallet-session-coordinator',
|
|
223
250
|
reason: expectedPassphraseState ? 'session-recovery' : 'open-wallet',
|
|
224
251
|
deviceOnly: true,
|
|
225
252
|
});
|
|
226
|
-
|
|
253
|
+
mainPinSelected = true;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const selectStandardWallet = async () => {
|
|
258
|
+
// Main PIN authenticates the device; an empty host passphrase selects the standard derivation.
|
|
259
|
+
await selectMainPin();
|
|
260
|
+
if (device.features?.passphraseProtection === true) {
|
|
261
|
+
await askDevicePassphrase(device, {
|
|
262
|
+
passphrase: '',
|
|
263
|
+
on_device: false,
|
|
264
|
+
});
|
|
227
265
|
}
|
|
228
266
|
};
|
|
229
267
|
|
|
@@ -232,11 +270,16 @@ export async function getProtocolV2WalletSession(
|
|
|
232
270
|
if (cachedStandardSession) {
|
|
233
271
|
try {
|
|
234
272
|
if (options?.selectMainWalletBeforeRestore) {
|
|
235
|
-
await
|
|
273
|
+
await selectMainPin();
|
|
236
274
|
}
|
|
237
|
-
response = await getDeviceSession(
|
|
238
|
-
|
|
239
|
-
|
|
275
|
+
response = await getDeviceSession(
|
|
276
|
+
device,
|
|
277
|
+
buildDeviceSessionGetRequest({
|
|
278
|
+
sessionId: cachedStandardSession.sessionId,
|
|
279
|
+
expectedPassphraseState,
|
|
280
|
+
deriveCardano: options?.deriveCardano,
|
|
281
|
+
})
|
|
282
|
+
);
|
|
240
283
|
resumed = true;
|
|
241
284
|
} catch (error) {
|
|
242
285
|
device.clearStandardInternalState?.();
|
|
@@ -248,14 +291,22 @@ export async function getProtocolV2WalletSession(
|
|
|
248
291
|
}
|
|
249
292
|
|
|
250
293
|
if (!response) {
|
|
251
|
-
await
|
|
252
|
-
response = await getDeviceSession(
|
|
294
|
+
await selectStandardWallet();
|
|
295
|
+
response = await getDeviceSession(
|
|
296
|
+
device,
|
|
297
|
+
buildDeviceSessionGetRequest({ deriveCardano: options?.deriveCardano })
|
|
298
|
+
);
|
|
253
299
|
}
|
|
254
300
|
} else if (cachedSessionId && expectedPassphraseState) {
|
|
255
301
|
try {
|
|
256
|
-
response = await getDeviceSession(
|
|
257
|
-
|
|
258
|
-
|
|
302
|
+
response = await getDeviceSession(
|
|
303
|
+
device,
|
|
304
|
+
buildDeviceSessionGetRequest({
|
|
305
|
+
sessionId: cachedSessionId,
|
|
306
|
+
expectedPassphraseState,
|
|
307
|
+
deriveCardano: options?.deriveCardano,
|
|
308
|
+
})
|
|
309
|
+
);
|
|
259
310
|
resumed = true;
|
|
260
311
|
} catch (error) {
|
|
261
312
|
device.clearInternalState();
|
|
@@ -266,7 +317,13 @@ export async function getProtocolV2WalletSession(
|
|
|
266
317
|
}
|
|
267
318
|
} else if (expectedPassphraseState) {
|
|
268
319
|
try {
|
|
269
|
-
response = await getDeviceSession(
|
|
320
|
+
response = await getDeviceSession(
|
|
321
|
+
device,
|
|
322
|
+
buildDeviceSessionGetRequest({
|
|
323
|
+
expectedPassphraseState,
|
|
324
|
+
deriveCardano: options?.deriveCardano,
|
|
325
|
+
})
|
|
326
|
+
);
|
|
270
327
|
} catch (error) {
|
|
271
328
|
if (options?.resumeOnly || !isWalletSessionInvalidError(error)) {
|
|
272
329
|
throw error;
|
|
@@ -279,7 +336,7 @@ export async function getProtocolV2WalletSession(
|
|
|
279
336
|
device.clearInternalState();
|
|
280
337
|
throw ERRORS.TypedError(HardwareErrorCode.WalletSessionInvalid);
|
|
281
338
|
}
|
|
282
|
-
response = await selectDeviceSession(device, expectedPassphraseState);
|
|
339
|
+
response = await selectDeviceSession(device, expectedPassphraseState, options?.deriveCardano);
|
|
283
340
|
}
|
|
284
341
|
|
|
285
342
|
let { message } = response;
|
|
@@ -302,11 +359,14 @@ export async function getProtocolV2WalletSession(
|
|
|
302
359
|
}
|
|
303
360
|
if (options?.onlyMainPin) {
|
|
304
361
|
device.clearStandardInternalState?.();
|
|
305
|
-
await
|
|
306
|
-
response = await getDeviceSession(
|
|
362
|
+
await selectStandardWallet();
|
|
363
|
+
response = await getDeviceSession(
|
|
364
|
+
device,
|
|
365
|
+
buildDeviceSessionGetRequest({ deriveCardano: options?.deriveCardano })
|
|
366
|
+
);
|
|
307
367
|
} else {
|
|
308
368
|
device.clearInternalState();
|
|
309
|
-
response = await selectDeviceSession(device, expectedPassphraseState);
|
|
369
|
+
response = await selectDeviceSession(device, expectedPassphraseState, options?.deriveCardano);
|
|
310
370
|
}
|
|
311
371
|
message = response.message;
|
|
312
372
|
try {
|
|
@@ -342,7 +402,7 @@ export async function getProtocolV2WalletSession(
|
|
|
342
402
|
return {
|
|
343
403
|
passphraseState: message.btc_test_address,
|
|
344
404
|
newSession: message.session_id,
|
|
345
|
-
unlockedAttachPin:
|
|
405
|
+
unlockedAttachPin: mainPinSelected ? false : undefined,
|
|
346
406
|
resumed,
|
|
347
407
|
};
|
|
348
408
|
}
|
package/src/utils/pro2Nft.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const PRO2_NFT_DEFAULT_PACE_MS = 20;
|
|
|
14
14
|
export const PRO2_NFT_DEFAULT_TIMEOUT_MS = 15_000;
|
|
15
15
|
export const PRO2_NFT_MIN_CHUNK_SIZE = 64;
|
|
16
16
|
export const PRO2_NFT_MAX_CHUNK_SIZE = 2048;
|
|
17
|
+
export const PRO2_NFT_MAX_ITEMS = 10;
|
|
17
18
|
|
|
18
19
|
export type Pro2NftImage = {
|
|
19
20
|
width: number;
|
|
@@ -28,6 +29,56 @@ export type Pro2NftBundle = {
|
|
|
28
29
|
metadata: Uint8Array;
|
|
29
30
|
};
|
|
30
31
|
|
|
32
|
+
const PRO2_NFT_BASENAME_PATTERN = /^nft-[0-9a-f]{8}-[1-9][0-9]*$/;
|
|
33
|
+
|
|
34
|
+
type Pro2NftFileType = 'image' | 'thumbnail' | 'metadata';
|
|
35
|
+
|
|
36
|
+
function parsePro2NftFile(listedPath: string):
|
|
37
|
+
| {
|
|
38
|
+
basename: string;
|
|
39
|
+
fileType: Pro2NftFileType;
|
|
40
|
+
}
|
|
41
|
+
| undefined {
|
|
42
|
+
const filename = listedPath.trim().split('/').at(-1);
|
|
43
|
+
if (!filename) return undefined;
|
|
44
|
+
|
|
45
|
+
let basename: string | undefined;
|
|
46
|
+
let fileType: Pro2NftFileType | undefined;
|
|
47
|
+
if (filename.endsWith('_m.bin')) {
|
|
48
|
+
basename = filename.slice(0, -'_m.bin'.length);
|
|
49
|
+
fileType = 'thumbnail';
|
|
50
|
+
} else if (filename.endsWith('.json')) {
|
|
51
|
+
basename = filename.slice(0, -'.json'.length);
|
|
52
|
+
fileType = 'metadata';
|
|
53
|
+
} else if (filename.endsWith('.bin')) {
|
|
54
|
+
basename = filename.slice(0, -'.bin'.length);
|
|
55
|
+
fileType = 'image';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return basename && fileType && PRO2_NFT_BASENAME_PATTERN.test(basename)
|
|
59
|
+
? { basename, fileType }
|
|
60
|
+
: undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getCompletePro2NftBasenames(childFiles?: string): Set<string> {
|
|
64
|
+
const filesByBasename = new Map<string, Set<Pro2NftFileType>>();
|
|
65
|
+
|
|
66
|
+
for (const listedPath of childFiles?.split('\n') ?? []) {
|
|
67
|
+
const file = parsePro2NftFile(listedPath);
|
|
68
|
+
if (file) {
|
|
69
|
+
const fileTypes = filesByBasename.get(file.basename) ?? new Set<Pro2NftFileType>();
|
|
70
|
+
fileTypes.add(file.fileType);
|
|
71
|
+
filesByBasename.set(file.basename, fileTypes);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return new Set(
|
|
76
|
+
[...filesByBasename.entries()]
|
|
77
|
+
.filter(([, fileTypes]) => fileTypes.size === 3)
|
|
78
|
+
.map(([basename]) => basename)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
31
82
|
function utf8Length(value: string): number {
|
|
32
83
|
return new TextEncoder().encode(value).byteLength;
|
|
33
84
|
}
|