@onekeyfe/hd-transport-web-device 1.2.0-alpha.16 → 1.2.0-alpha.161

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.
@@ -1,26 +1,34 @@
1
1
  import transport, {
2
2
  PROTOCOL_V1_MESSAGE_HEADER_SIZE,
3
+ PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
3
4
  PROTOCOL_V2_CHANNEL_BLE_UART,
4
5
  ProtocolV2FrameAssembler,
5
6
  ProtocolV2LinkManager,
7
+ TRANSPORT_EVENT,
6
8
  bytesToHex,
7
9
  hexToBytes,
8
10
  probeProtocolV2 as probeProtocolV2Helper,
11
+ writeProtocolV2BleFrame,
9
12
  } from '@onekeyfe/hd-transport';
10
13
  import {
14
+ EBleDisconnectReason,
11
15
  ERRORS,
12
16
  HardwareErrorCode,
13
17
  HardwareErrorCodeMessage,
14
18
  createDeferred,
15
19
  isHeaderChunk,
16
- wait,
17
20
  } from '@onekeyfe/hd-shared';
18
21
 
19
- import { createTransportCallLog, shouldSuppressHighVolumeCallLog } from './transportLog';
22
+ import { resolveBlePacketCapacity } from './ble-packet-capacity';
20
23
 
21
24
  import type { Deferred } from '@onekeyfe/hd-shared';
22
25
  import type { DesktopAPI } from '@onekeyfe/hd-transport-electron';
23
- import type { OneKeyDeviceInfo, ProtocolType, TransportCallOptions } from '@onekeyfe/hd-transport';
26
+ import type {
27
+ OneKeyDeviceInfo,
28
+ ProtocolType,
29
+ ProtocolV2CallContext,
30
+ TransportCallOptions,
31
+ } from '@onekeyfe/hd-transport';
24
32
  import type EventEmitter from 'events';
25
33
 
26
34
  const { parseConfigure, ProtocolV1, check } = transport;
@@ -35,6 +43,7 @@ export type BleAcquireInput = {
35
43
  uuid: string;
36
44
  forceCleanRunPromise?: boolean;
37
45
  expectedProtocol?: ProtocolType;
46
+ protocolHint?: ProtocolType;
38
47
  };
39
48
 
40
49
  interface PacketProcessResult {
@@ -43,10 +52,6 @@ interface PacketProcessResult {
43
52
  error?: string;
44
53
  }
45
54
 
46
- function inferProtocolHintFromDeviceName(name?: string | null): ProtocolType | undefined {
47
- return /\bpro\s*2\b/i.test(name ?? '') ? 'V2' : undefined;
48
- }
49
-
50
55
  const toBleDescriptor = (
51
56
  device: { id: string; name: string | null },
52
57
  protocolType?: ProtocolType
@@ -60,29 +65,33 @@ const toBleDescriptor = (
60
65
  ...(protocolType ? { protocolType } : {}),
61
66
  } as OneKeyDeviceInfo);
62
67
 
63
- const BLE_PACKET_SIZE = 192;
68
+ const BLE_PACKET_SIZE_FALLBACK = 192;
69
+ const BLE_PACKET_SIZE_MAXIMUM = 244;
64
70
  const BLE_WRITE_DELAY_MS = 5;
65
- const BLE_RESPONSE_TIMEOUT_MS = 30_000;
66
- const PROTOCOL_PROBE_TIMEOUT_MS = 1000;
71
+ const PROTOCOL_PROBE_TIMEOUT_MS = 3000;
67
72
  const PROTOCOL_V2_PROBE_TIMEOUT_MS = 5000;
68
73
 
69
74
  /**
70
75
  * Desktop Electron BLE transport with automatic Protocol V1/V2 detection.
71
76
  *
72
77
  * Protocol V1 devices continue using chunked packets. Protocol V2 is detected
73
- * after a Protocol V1 Initialize timeout by probing Protocol V2 Ping.
78
+ * after a Protocol V1 GetFeatures timeout by probing Protocol V2 Ping.
74
79
  */
75
80
  export default class ElectronBleTransport {
76
81
  private _messages: ReturnType<typeof transport.parseConfigure> | undefined;
77
82
 
78
83
  private _messagesV2: ReturnType<typeof transport.parseConfigure> | undefined;
79
84
 
85
+ private protocolV2SchemaConfiguration: string | undefined;
86
+
80
87
  name = 'ElectronBleTransport';
81
88
 
82
89
  configured = false;
83
90
 
84
91
  runPromise: Deferred<Uint8Array | string> | null = null;
85
92
 
93
+ private runPromiseDeviceId: string | null = null;
94
+
86
95
  Log?: any;
87
96
 
88
97
  emitter?: EventEmitter;
@@ -93,6 +102,13 @@ export default class ElectronBleTransport {
93
102
 
94
103
  private deviceProtocolHints: Map<string, ProtocolType> = new Map();
95
104
 
105
+ /** Endpoints that answered a V2 probe in this transport lifetime. Survives disconnect. */
106
+ private confirmedProtocolV2 = new Set<string>();
107
+
108
+ private deviceMtus: Map<string, number> = new Map();
109
+
110
+ private devicePacketCapacities: Map<string, number> = new Map();
111
+
96
112
  private v1Buffers: Map<string, { buffer: number[]; bufferLength: number }> = new Map();
97
113
 
98
114
  private v2Assemblers: Map<string, ProtocolV2FrameAssembler> = new Map();
@@ -117,14 +133,27 @@ export default class ElectronBleTransport {
117
133
  this.rejectProtocolV2Frames(uuid, new Error(reason));
118
134
  this.Log?.debug('[Electron BLE] Protocol V2 link invalidated:', uuid, reason);
119
135
  if (reason.startsWith('Protocol V2 link-fatal error:')) {
120
- await this.release(uuid);
136
+ await this.releaseNative(uuid);
121
137
  }
122
138
  },
123
139
  });
124
140
 
141
+ /** One-shot guard so a missing preload `release` bridge is reported once. */
142
+ private warnedMissingRelease = false;
143
+
125
144
  private notificationCleanups: Map<string, () => void> = new Map();
126
145
 
127
- private disconnectCleanups: Map<string, () => void> = new Map();
146
+ private mtuCleanups: Map<string, () => void> = new Map();
147
+
148
+ /**
149
+ * Transport-lifetime subscription to host BLE disconnects.
150
+ *
151
+ * This must NOT be scoped to acquire()/release(): a logical release keeps the
152
+ * native link alive for the keep-alive window, so a device that drops while
153
+ * idle would otherwise go unobserved and consumers would never learn it left
154
+ * (OK-60486).
155
+ */
156
+ private hostDisconnectCleanup?: () => void;
128
157
 
129
158
  private notificationTokens: Map<string, number> = new Map();
130
159
 
@@ -167,12 +196,19 @@ export default class ElectronBleTransport {
167
196
  .catch(error => this.Log?.debug('[Electron BLE] link cleanup failed:', error));
168
197
  this.connectedDevices.delete(deviceId);
169
198
  this.deviceProtocol.delete(deviceId);
199
+ this.deviceMtus.delete(deviceId);
200
+ this.devicePacketCapacities.delete(deviceId);
170
201
  // Keep deviceProtocolHints — it's inferred from device name (e.g. "Pro 2" → V2)
171
202
  // and doesn't depend on connection state. Preserving it avoids redundant V1 probe on reconnect.
172
203
  this.v1Buffers.delete(deviceId);
173
204
  this.v2Assemblers.delete(deviceId);
174
205
  this.resetProtocolV2Frames(deviceId);
175
206
  this.notificationTokens.delete(deviceId);
207
+ if (this.runPromise && this.runPromiseDeviceId === deviceId) {
208
+ this.runPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected));
209
+ this.runPromise = null;
210
+ this.runPromiseDeviceId = null;
211
+ }
176
212
 
177
213
  const notifyCleanup = this.notificationCleanups.get(deviceId);
178
214
  if (notifyCleanup) {
@@ -180,10 +216,10 @@ export default class ElectronBleTransport {
180
216
  this.notificationCleanups.delete(deviceId);
181
217
  }
182
218
 
183
- const disconnectCleanup = this.disconnectCleanups.get(deviceId);
184
- if (disconnectCleanup) {
185
- disconnectCleanup();
186
- this.disconnectCleanups.delete(deviceId);
219
+ const mtuCleanup = this.mtuCleanups.get(deviceId);
220
+ if (mtuCleanup) {
221
+ mtuCleanup();
222
+ this.mtuCleanups.delete(deviceId);
187
223
  }
188
224
  }
189
225
 
@@ -198,19 +234,67 @@ export default class ElectronBleTransport {
198
234
  );
199
235
  }
200
236
 
237
+ this.subscribeHostDisconnects();
238
+
201
239
  this.Log?.debug('[Electron BLE] Transport initialized');
202
240
  }
203
241
 
242
+ /**
243
+ * One host subscription for the whole transport lifetime. init() can run
244
+ * again after an SDK reset, so drop the previous listener first rather than
245
+ * stacking duplicates.
246
+ */
247
+ private subscribeHostDisconnects() {
248
+ this.hostDisconnectCleanup?.();
249
+ this.hostDisconnectCleanup = window.desktopApi?.nobleBle?.onDeviceDisconnected(
250
+ (disconnectedDevice: { id: string; name: string; reason?: EBleDisconnectReason }) => {
251
+ const uuid = disconnectedDevice?.id;
252
+ if (!uuid) return;
253
+
254
+ // The link is gone, so renderer-side state must go with it; the next
255
+ // acquire reconnects from scratch.
256
+ this.cleanupDeviceState(uuid);
257
+
258
+ // Every link drop is reported, including the main process reclaiming
259
+ // an idle link on its keep-alive timer: consumers track whether a BLE
260
+ // link is live, not whether the peripheral is theoretically in range,
261
+ // and a link we closed ourselves is still a closed link. Nothing
262
+ // reconnects on its own, so this settles once until the user acts.
263
+ // `reason` is carried for diagnostics only — behaviour is uniform.
264
+ this.Log?.debug(
265
+ '[Electron BLE] Device link dropped:',
266
+ uuid,
267
+ disconnectedDevice.reason ?? EBleDisconnectReason.DeviceDisconnected
268
+ );
269
+
270
+ this.emitter?.emit(TRANSPORT_EVENT.DEVICE_DISCONNECT, {
271
+ name: disconnectedDevice.name,
272
+ id: uuid,
273
+ connectId: uuid,
274
+ });
275
+ }
276
+ );
277
+ }
278
+
204
279
  configure(signedData: any) {
205
280
  this._messages = parseConfigure(signedData);
206
281
  this.configured = true;
207
282
  }
208
283
 
209
284
  configureProtocolV2(signedData: any) {
285
+ const configuration = typeof signedData === 'string' ? signedData : JSON.stringify(signedData);
286
+ if (this.protocolV2SchemaConfiguration === configuration) {
287
+ return;
288
+ }
289
+
290
+ const isReconfiguration = this.protocolV2SchemaConfiguration !== undefined;
210
291
  this._messagesV2 = parseConfigure(signedData);
211
- this.protocolV2Links
212
- .invalidateAllLinks('Protocol V2 schema reconfigured')
213
- .catch(error => this.Log?.debug('[Electron BLE] schema link cleanup failed:', error));
292
+ this.protocolV2SchemaConfiguration = configuration;
293
+ if (isReconfiguration) {
294
+ this.protocolV2Links
295
+ .invalidateAllLinks('Protocol V2 schema reconfigured')
296
+ .catch(error => this.Log?.debug('[Electron BLE] schema link cleanup failed:', error));
297
+ }
214
298
  }
215
299
 
216
300
  async listen() {
@@ -226,10 +310,6 @@ export default class ElectronBleTransport {
226
310
  this.Log?.debug(`[Electron BLE] enumerate found ${devices.length} device(s):`);
227
311
  for (const dev of devices) {
228
312
  this.Log?.debug(`[Electron BLE] id="${dev.id}" name="${dev.name}"`);
229
- const protocolHint = inferProtocolHintFromDeviceName(dev.name);
230
- if (protocolHint) {
231
- this.deviceProtocolHints.set(dev.id, protocolHint);
232
- }
233
313
  }
234
314
  return devices.map(device => toBleDescriptor(device));
235
315
  } catch (error) {
@@ -252,8 +332,8 @@ export default class ElectronBleTransport {
252
332
  if (forceCleanRunPromise && this.runPromise) {
253
333
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
254
334
  this.runPromise.reject(error);
255
- this.rejectAllProtocolV2Frames(error);
256
335
  this.runPromise = null;
336
+ this.runPromiseDeviceId = null;
257
337
  }
258
338
 
259
339
  try {
@@ -267,7 +347,7 @@ export default class ElectronBleTransport {
267
347
  }
268
348
  const protocolHint = expectedProtocol
269
349
  ? undefined
270
- : this.deviceProtocolHints.get(uuid) ?? inferProtocolHintFromDeviceName(device.name);
350
+ : input.protocolHint ?? this.deviceProtocolHints.get(uuid);
271
351
  if (protocolHint) {
272
352
  this.deviceProtocolHints.set(uuid, protocolHint);
273
353
  }
@@ -279,35 +359,28 @@ export default class ElectronBleTransport {
279
359
  this.handleBluetoothError(error);
280
360
  }
281
361
 
362
+ const mtuCleanup = this.createMtuSubscription(uuid);
363
+ if (mtuCleanup) {
364
+ this.mtuCleanups.set(uuid, mtuCleanup);
365
+ }
366
+
282
367
  this.v1Buffers.set(uuid, { buffer: [], bufferLength: 0 });
283
- this.v2Assemblers.set(uuid, new ProtocolV2FrameAssembler());
368
+ this.v2Assemblers.set(uuid, new ProtocolV2FrameAssembler(PROTOCOL_V2_BLE_FRAME_MAX_BYTES));
284
369
 
285
370
  await window.desktopApi.nobleBle.subscribe(uuid);
371
+ await this.refreshBlePacketCapacity(uuid);
286
372
 
287
373
  const cleanup = this.createNotificationSubscription(uuid);
288
374
  this.notificationCleanups.set(uuid, cleanup);
289
375
 
290
- const disconnectCleanup = window.desktopApi.nobleBle.onDeviceDisconnected(
291
- (disconnectedDevice: any) => {
292
- if (disconnectedDevice.id === uuid) {
293
- this.cleanupDeviceState(uuid);
294
- this.emitter?.emit('device-disconnect', {
295
- name: disconnectedDevice.name,
296
- id: disconnectedDevice.id,
297
- connectId: disconnectedDevice.id,
298
- });
299
- }
300
- }
301
- );
302
- this.disconnectCleanups.set(uuid, disconnectCleanup);
303
-
304
376
  const protocolType = await this.detectProtocol(uuid, expectedProtocol, protocolHint);
305
-
306
- this.emitter?.emit('device-connect', {
307
- name: device.name,
308
- id: device.id,
309
- connectId: device.id,
310
- });
377
+ if (protocolType === 'V2') {
378
+ this.Log?.debug('[Electron BLE] Protocol V2 write configured', {
379
+ writeMode: 'withoutResponse',
380
+ negotiatedMtu: this.deviceMtus.get(uuid),
381
+ packetCapacity: this.devicePacketCapacities.get(uuid) ?? BLE_PACKET_SIZE_FALLBACK,
382
+ });
383
+ }
311
384
 
312
385
  return {
313
386
  ...toBleDescriptor({ id: device.id, name: device.name }, protocolType),
@@ -328,9 +401,25 @@ export default class ElectronBleTransport {
328
401
  }
329
402
  }
330
403
 
331
- async release(id: string) {
404
+ async release(id: string, _onclose?: boolean, keepSession?: boolean) {
332
405
  try {
333
406
  await this.protocolV2Links.invalidateLink(id, 'Electron BLE transport released');
407
+ await this.releaseLogical(id, keepSession);
408
+ } catch (error) {
409
+ this.Log?.error('[Electron BLE] release failed:', error);
410
+ this.cleanupDeviceState(id);
411
+ }
412
+ }
413
+
414
+ // DeviceConnector.disconnect feature-detects this; without it REQUIRE_DISCONNECT
415
+ // recovery is a no-op and keep-alive hands the wedged link to every retry.
416
+ async disconnect(id: string) {
417
+ return this.releaseNative(id);
418
+ }
419
+
420
+ // Hard teardown, error paths only: a link presumed dead must not be reused.
421
+ private async releaseNative(id: string) {
422
+ try {
334
423
  if (this.connectedDevices.has(id)) {
335
424
  if (window.desktopApi?.nobleBle) {
336
425
  await window.desktopApi.nobleBle.unsubscribe(id);
@@ -344,9 +433,38 @@ export default class ElectronBleTransport {
344
433
  }
345
434
  }
346
435
 
347
- private createProtocolMismatchError(expected: ProtocolType) {
436
+ // Logical release: link and subscription stay up for the next call. Renderer
437
+ // listeners are still torn down, or fresh ones would double-process packets.
438
+ private async releaseLogical(id: string, keepSession?: boolean) {
439
+ try {
440
+ if (!this.connectedDevices.has(id)) return;
441
+ this.cleanupDeviceState(id);
442
+
443
+ const release = window.desktopApi?.nobleBle?.release;
444
+ if (!release) {
445
+ // Degraded, not broken: say it once, it just looks like a slow device.
446
+ if (!this.warnedMissingRelease) {
447
+ this.warnedMissingRelease = true;
448
+ this.Log?.error(
449
+ '[Electron BLE] desktopApi.nobleBle.release is missing from the preload bridge; ' +
450
+ 'keep-alive idle countdown will never start — map NOBLE_BLE_RELEASE in the desktop preload'
451
+ );
452
+ }
453
+ return;
454
+ }
455
+ await release(id, keepSession);
456
+ } catch (error) {
457
+ this.Log?.error('[Electron BLE] logical release failed:', error);
458
+ this.cleanupDeviceState(id);
459
+ }
460
+ }
461
+
462
+ private createProtocolMismatchError(expected: ProtocolType, uuid: string) {
463
+ // A generic Ping miss is not a bond failure. Only a later miss after this
464
+ // endpoint already answered V2, or a native encryption/pairing error, is.
465
+ const isStaleV2Bond = expected === 'V2' && this.confirmedProtocolV2.has(uuid);
348
466
  return ERRORS.TypedError(
349
- HardwareErrorCode.RuntimeError,
467
+ isStaleV2Bond ? HardwareErrorCode.BleDeviceBondError : HardwareErrorCode.RuntimeError,
350
468
  `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`
351
469
  );
352
470
  }
@@ -354,7 +472,7 @@ export default class ElectronBleTransport {
354
472
  private createProtocolDetectionError() {
355
473
  return ERRORS.TypedError(
356
474
  HardwareErrorCode.BleTimeoutError,
357
- 'Unable to detect BLE protocol: device did not respond to Protocol V1 Initialize or Protocol V2 Ping'
475
+ 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping'
358
476
  );
359
477
  }
360
478
 
@@ -375,33 +493,38 @@ export default class ElectronBleTransport {
375
493
  this.Log?.debug(`[Electron BLE] detectProtocol: uuid=${uuid} -> V1 (expected)`);
376
494
  return 'V1';
377
495
  }
378
- throw this.createProtocolMismatchError(expectedProtocol);
496
+ throw this.createProtocolMismatchError(expectedProtocol, uuid);
379
497
  }
380
498
 
381
499
  if (expectedProtocol === 'V2') {
382
- // 免探测路径:调用方显式承诺该设备是 V2(例如固件升级重启后的重连场景,
383
- // 上层已经探测过协议并通过 expectedProtocol 传回),这里不再重复探测。
384
- this.deviceProtocol.set(uuid, 'V2');
385
- this.Log?.debug(`[Electron BLE] detectProtocol: uuid=${uuid} -> V2 (expected)`);
386
- return 'V2';
500
+ if (await this.probeProtocolV2(uuid)) {
501
+ this.deviceProtocol.set(uuid, 'V2');
502
+ this.confirmedProtocolV2.add(uuid);
503
+ this.Log?.debug(`[Electron BLE] detectProtocol: uuid=${uuid} -> V2 (expected)`);
504
+ return 'V2';
505
+ }
506
+ throw this.createProtocolMismatchError(expectedProtocol, uuid);
387
507
  }
388
508
 
389
- // 项目约束:协议判断必须在连接后主动探测,不能依赖设备名/PID/descriptor。
390
- // 设备名 hint(如 "Pro 2")只用于调整探测顺序:hint=V2 时先探 V2、失败回落 V1,
391
- // 不能作为最终结论。
509
+ // Protocol must be actively probed after connection. Name, PID, and descriptors only
510
+ // influence probe order; a V2 hint probes V2 first and falls back to V1.
392
511
  const probeOrder: ProtocolType[] =
393
512
  protocolHint === 'V2' || this.deviceProtocol.get(uuid) === 'V2' ? ['V2', 'V1'] : ['V1', 'V2'];
394
513
 
395
514
  for (let i = 0; i < probeOrder.length; i += 1) {
396
515
  const protocol = probeOrder[i];
397
516
  if (i > 0) {
398
- // 上一个协议探测失败后,重置订阅与缓冲,避免残留数据干扰下一个协议的探测。
517
+ // Keep the physical BLE link while switching probes. Reconnecting here can
518
+ // summon a second OS pairing prompt for a device that is still onboarding.
399
519
  await this.resetProbeStateAfterProtocolProbe(uuid, probeOrder[i - 1]);
400
520
  }
401
521
  const detected =
402
522
  protocol === 'V1' ? await this.probeProtocolV1(uuid) : await this.probeProtocolV2(uuid);
403
523
  if (detected) {
404
524
  this.deviceProtocol.set(uuid, protocol);
525
+ if (protocol === 'V2') {
526
+ this.confirmedProtocolV2.add(uuid);
527
+ }
405
528
  this.Log?.debug(`[Electron BLE] detectProtocol: uuid=${uuid} -> ${protocol}`);
406
529
  return protocol;
407
530
  }
@@ -435,33 +558,20 @@ export default class ElectronBleTransport {
435
558
  this.v1Buffers.set(uuid, { buffer: [], bufferLength: 0 });
436
559
  this.v2Assemblers.get(uuid)?.reset();
437
560
  this.resetProtocolV2Frames(uuid);
438
- if (this.runPromise) {
561
+ if (this.runPromise && this.runPromiseDeviceId === uuid) {
439
562
  const error = ERRORS.TypedError(HardwareErrorCode.BleForceCleanRunPromise);
440
563
  this.runPromise.reject(error);
441
564
  this.runPromise = null;
565
+ this.runPromiseDeviceId = null;
442
566
  }
443
567
 
444
- const notifyCleanup = this.notificationCleanups.get(uuid);
445
- if (notifyCleanup) {
446
- notifyCleanup();
447
- this.notificationCleanups.delete(uuid);
448
- }
449
- this.notificationTokens.delete(uuid);
450
-
451
- try {
452
- await window.desktopApi?.nobleBle?.unsubscribe(uuid);
453
- } catch (error) {
454
- this.Log?.debug(`[Electron BLE] unsubscribe after Protocol ${protocol} probe failed:`, error);
455
- }
456
- try {
457
- await window.desktopApi?.nobleBle?.subscribe(uuid);
458
- } catch (error) {
459
- this.Log?.debug(`[Electron BLE] resubscribe after Protocol ${protocol} probe failed:`, error);
460
- throw error;
568
+ // A timed-out V1 probe retires its renderer notification token so a late V1
569
+ // response cannot satisfy the V2 probe. Install a fresh listener without
570
+ // touching the native GATT subscription or physical connection.
571
+ if (!this.notificationCleanups.has(uuid)) {
572
+ const cleanup = this.createNotificationSubscription(uuid);
573
+ this.notificationCleanups.set(uuid, cleanup);
461
574
  }
462
-
463
- const cleanup = this.createNotificationSubscription(uuid);
464
- this.notificationCleanups.set(uuid, cleanup);
465
575
  }
466
576
 
467
577
  private async probeProtocolV1(uuid: string) {
@@ -471,11 +581,13 @@ export default class ElectronBleTransport {
471
581
 
472
582
  try {
473
583
  this.deviceProtocol.set(uuid, 'V1');
474
- await this.callProtocolV1(uuid, 'Initialize', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
584
+ // GetFeatures identifies Protocol V1 without resetting an existing wallet
585
+ // session before Core has a chance to restore a hidden wallet.
586
+ await this.callProtocolV1(uuid, 'GetFeatures', {}, { timeoutMs: PROTOCOL_PROBE_TIMEOUT_MS });
475
587
  return true;
476
588
  } catch (error) {
477
589
  this.clearProbeProtocol(uuid, 'V1');
478
- this.Log?.debug('[Electron BLE] Protocol V1 Initialize probe failed:', error);
590
+ this.Log?.debug('[Electron BLE] Protocol V1 GetFeatures probe failed:', error);
479
591
  return false;
480
592
  }
481
593
  }
@@ -503,35 +615,63 @@ export default class ElectronBleTransport {
503
615
  return detected;
504
616
  }
505
617
 
506
- private async writeWithChunking(uuid: string, hexData: string): Promise<void> {
507
- const totalBytes = hexData.length / 2;
508
-
509
- if (totalBytes <= BLE_PACKET_SIZE) {
510
- await wait(BLE_WRITE_DELAY_MS);
511
- await this.writeOnce(uuid, hexData);
512
- return;
618
+ private async writeOnce(uuid: string, hexData: string): Promise<void> {
619
+ const nobleBle = window.desktopApi?.nobleBle;
620
+ if (!nobleBle) {
621
+ throw new Error('Noble BLE API not available');
513
622
  }
514
623
 
515
- for (let offset = 0; offset < hexData.length; ) {
516
- const chunkHexLen = Math.min(BLE_PACKET_SIZE * 2, hexData.length - offset);
517
- const chunkHex = hexData.substring(offset, offset + chunkHexLen);
518
- offset += chunkHexLen;
624
+ await nobleBle.write(uuid, hexData, { pacingDelayMs: 0 });
625
+ }
519
626
 
520
- await this.writeOnce(uuid, chunkHex);
627
+ private async refreshBlePacketCapacity(uuid: string): Promise<void> {
628
+ const device = await window.desktopApi?.nobleBle?.getDevice(uuid);
629
+ this.updateBlePacketCapacity(uuid, device?.mtu);
630
+ }
521
631
 
522
- if (offset < hexData.length) {
523
- await wait(BLE_WRITE_DELAY_MS);
524
- }
632
+ private updateBlePacketCapacity(uuid: string, mtu?: number): void {
633
+ const packetCapacity = resolveBlePacketCapacity(
634
+ mtu,
635
+ BLE_PACKET_SIZE_MAXIMUM,
636
+ BLE_PACKET_SIZE_FALLBACK
637
+ );
638
+ if (typeof mtu === 'number') {
639
+ this.deviceMtus.set(uuid, mtu);
640
+ } else {
641
+ this.deviceMtus.delete(uuid);
525
642
  }
643
+ this.devicePacketCapacities.set(uuid, packetCapacity);
526
644
  }
527
645
 
528
- private async writeOnce(uuid: string, hexData: string): Promise<void> {
529
- const nobleBle = window.desktopApi?.nobleBle;
530
- if (!nobleBle) {
531
- throw new Error('Noble BLE API not available');
532
- }
646
+ private createMtuSubscription(uuid: string): (() => void) | undefined {
647
+ const onMtuChanged = window.desktopApi?.nobleBle?.onMtuChanged;
648
+ if (!onMtuChanged) return undefined;
649
+ return onMtuChanged(device => {
650
+ if (device.id === uuid) {
651
+ this.updateBlePacketCapacity(uuid, device.mtu);
652
+ }
653
+ });
654
+ }
533
655
 
534
- await nobleBle.write(uuid, hexData);
656
+ private writeProtocolV2Frame(
657
+ uuid: string,
658
+ frame: Uint8Array,
659
+ context: ProtocolV2CallContext,
660
+ assertCurrentGeneration: () => void
661
+ ) {
662
+ const packetCapacity = this.devicePacketCapacities.get(uuid) ?? BLE_PACKET_SIZE_FALLBACK;
663
+ const shouldPace = !context.highThroughput;
664
+ return writeProtocolV2BleFrame({
665
+ frame,
666
+ packetCapacity,
667
+ assertActive: assertCurrentGeneration,
668
+ signal: context.signal,
669
+ abortMessage: `Protocol V2 BLE write aborted for ${context.messageName}`,
670
+ initialDelayMs: shouldPace && frame.length <= packetCapacity ? BLE_WRITE_DELAY_MS : 0,
671
+ burstSize: shouldPace ? 1 : undefined,
672
+ burstPauseMs: shouldPace ? BLE_WRITE_DELAY_MS : 0,
673
+ writePacket: packet => this.writeOnce(uuid, bytesToHex(packet)),
674
+ });
535
675
  }
536
676
 
537
677
  private handleNotification(deviceId: string, hexData: string): void {
@@ -540,7 +680,7 @@ export default class ElectronBleTransport {
540
680
  const error = ERRORS.TypedError(HardwareErrorCode.BleDeviceBondedCanceled);
541
681
  if (this.deviceProtocol.get(deviceId) === 'V2') {
542
682
  this.rejectProtocolV2Frames(deviceId, error);
543
- } else if (this.runPromise) {
683
+ } else if (this.runPromise && this.runPromiseDeviceId === deviceId) {
544
684
  this.runPromise.reject(error);
545
685
  }
546
686
  return;
@@ -595,17 +735,8 @@ export default class ElectronBleTransport {
595
735
  this.getProtocolV2FrameQueue(uuid).push(frame);
596
736
  }
597
737
 
598
- private rejectAllProtocolV2Frames(error: Error) {
599
- this.v2FrameQueues.clear();
600
- for (const framePromise of this.v2FramePromises.values()) {
601
- framePromise.reject(error);
602
- }
603
- this.v2FramePromises.clear();
604
- }
605
-
606
738
  private resetProtocolV2Frames(uuid: string) {
607
- this.v2FrameQueues.delete(uuid);
608
- this.v2FramePromises.delete(uuid);
739
+ this.rejectProtocolV2Frames(uuid, new Error(`Protocol V2 frame state reset for ${uuid}`));
609
740
  }
610
741
 
611
742
  private rejectProtocolV2Frames(uuid: string, error: Error) {
@@ -639,13 +770,18 @@ export default class ElectronBleTransport {
639
770
 
640
771
  if (result.error) {
641
772
  this.Log?.error('[Electron BLE] Protocol V1 packet processing error:', result.error);
642
- if (this.runPromise) {
773
+ if (this.runPromise && this.runPromiseDeviceId === deviceId) {
643
774
  this.runPromise.reject(ERRORS.TypedError(HardwareErrorCode.BleWriteCharacteristicError));
644
775
  }
645
776
  return;
646
777
  }
647
778
 
648
- if (result.isComplete && result.completePacket && this.runPromise) {
779
+ if (
780
+ result.isComplete &&
781
+ result.completePacket &&
782
+ this.runPromise &&
783
+ this.runPromiseDeviceId === deviceId
784
+ ) {
649
785
  this.runPromise.resolve(result.completePacket);
650
786
  }
651
787
  }
@@ -671,16 +807,25 @@ export default class ElectronBleTransport {
671
807
  `Device protocol has not been detected for ${uuid}`
672
808
  );
673
809
  }
674
- if (!shouldSuppressHighVolumeCallLog(name)) {
675
- this.Log?.debug('transport call', createTransportCallLog(name, protocol));
676
- }
677
-
678
810
  if (protocol === 'V2') {
679
811
  return this.callProtocolV2(uuid, name, data, options);
680
812
  }
681
813
  return this.callProtocolV1(uuid, name, data, options);
682
814
  }
683
815
 
816
+ async post(uuid: string, name: string, data: Record<string, unknown>) {
817
+ if (this.deviceProtocol.get(uuid) === 'V2') {
818
+ await this.protocolV2Links.sendFlowControl(
819
+ uuid,
820
+ () => this.createProtocolV2Adapter(uuid),
821
+ name,
822
+ data
823
+ );
824
+ return;
825
+ }
826
+ await this.callProtocolV1(uuid, name, data);
827
+ }
828
+
684
829
  private async callProtocolV1(
685
830
  uuid: string,
686
831
  name: string,
@@ -699,6 +844,7 @@ export default class ElectronBleTransport {
699
844
  const runPromise = createDeferred<Uint8Array | string>();
700
845
  runPromise.promise.catch(() => undefined);
701
846
  this.runPromise = runPromise;
847
+ this.runPromiseDeviceId = uuid;
702
848
  const messages = this._messages;
703
849
  const buffers = ProtocolV1.encodeTransportPackets(messages, name, data);
704
850
  let timeout: ReturnType<typeof setTimeout> | undefined;
@@ -743,11 +889,24 @@ export default class ElectronBleTransport {
743
889
  return check.call(jsonData);
744
890
  } catch (e) {
745
891
  this.Log?.error('[Electron BLE] Protocol V1 call error:', e);
892
+ const isProbeTimeout =
893
+ name === 'GetFeatures' && options?.timeoutMs === PROTOCOL_PROBE_TIMEOUT_MS;
894
+ if ((e as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleTimeoutError) {
895
+ this.v1Buffers.set(uuid, { buffer: [], bufferLength: 0 });
896
+ const notifyCleanup = this.notificationCleanups.get(uuid);
897
+ notifyCleanup?.();
898
+ this.notificationCleanups.delete(uuid);
899
+ this.notificationTokens.delete(uuid);
900
+ if (!isProbeTimeout) {
901
+ await this.releaseNative(uuid);
902
+ }
903
+ }
746
904
  throw e;
747
905
  } finally {
748
906
  if (timeout) clearTimeout(timeout);
749
907
  if (this.runPromise === runPromise) {
750
908
  this.runPromise = null;
909
+ this.runPromiseDeviceId = null;
751
910
  }
752
911
  }
753
912
  }
@@ -762,10 +921,7 @@ export default class ElectronBleTransport {
762
921
  throw ERRORS.TypedError(HardwareErrorCode.TransportNotConfigured);
763
922
  }
764
923
 
765
- const callOptions = {
766
- ...options,
767
- timeoutMs: options?.timeoutMs ?? BLE_RESPONSE_TIMEOUT_MS,
768
- };
924
+ const callOptions = options;
769
925
 
770
926
  try {
771
927
  return await this.protocolV2Links.call(
@@ -791,16 +947,15 @@ export default class ElectronBleTransport {
791
947
 
792
948
  return {
793
949
  router: PROTOCOL_V2_CHANNEL_BLE_UART,
950
+ maxFrameBytes: PROTOCOL_V2_BLE_FRAME_MAX_BYTES,
794
951
  generation,
795
952
  prepareCall: () => {
796
953
  assertCurrentGeneration();
797
954
  this.v2Assemblers.get(uuid)?.reset();
798
955
  this.resetProtocolV2Frames(uuid);
799
956
  },
800
- writeFrame: (frame: Uint8Array) => {
801
- assertCurrentGeneration();
802
- return this.writeWithChunking(uuid, bytesToHex(frame));
803
- },
957
+ writeFrame: (frame: Uint8Array, context: ProtocolV2CallContext) =>
958
+ this.writeProtocolV2Frame(uuid, frame, context, assertCurrentGeneration),
804
959
  readFrame: async () => {
805
960
  assertCurrentGeneration();
806
961
  const rxFrame = await this.readProtocolV2Frame(uuid);
@@ -844,6 +999,12 @@ export default class ElectronBleTransport {
844
999
  bufferState.bufferLength = dataView.getInt32(5, false);
845
1000
  bufferState.buffer = [...data.subarray(3)];
846
1001
  } else {
1002
+ if (bufferState.buffer.length === 0) {
1003
+ // Tail of a cancelled call, arriving after the buffer was reset. Not an
1004
+ // `error`: the caller rejects the in-flight call on that field.
1005
+ this.Log?.debug('[Electron BLE] Orphan continuation chunk discarded');
1006
+ return { isComplete: false };
1007
+ }
847
1008
  bufferState.buffer = bufferState.buffer.concat([...data]);
848
1009
  }
849
1010