@onekeyfe/hd-transport-web-device 1.2.8-alpha.13 → 1.2.8-alpha.131

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/index.js CHANGED
@@ -51,11 +51,18 @@ const REPORT_ID = transport.PROTOCOL_V1_REPORT_ID;
51
51
  const HEADER_LENGTH = transport.PROTOCOL_V1_MESSAGE_HEADER_SIZE;
52
52
  const PACKET_IO_MAX_RETRIES = 3;
53
53
  const PACKET_IO_RETRY_DELAY = 300;
54
+ const USB_DEVICE_IN_USE = 'USB device is already in use';
55
+ const WEBUSB_LIFECYCLE_LOG_PREFIX = '[WebUsbTransport][lifecycle]';
56
+ const WEBUSB_ERROR_MESSAGE_LIMIT = 240;
54
57
  const PROTOCOL_V1_PROBE_TIMEOUT = 5000;
55
58
  const PROTOCOL_V2_PROBE_TIMEOUT = 1000;
56
59
  const EXPECTED_PROTOCOL_V2_PROBE_ATTEMPTS = 2;
57
60
  let activeWebUsbTransport;
58
61
  let usbDisconnectListenerAttached = false;
62
+ let nextWebUsbTransportInstanceId = 0;
63
+ const identityProbeHandles = new WeakSet();
64
+ const openingHandles = new WeakSet();
65
+ const normalHandleOwners = new WeakMap();
59
66
  function registerActiveWebUsbTransport(instance, usb) {
60
67
  activeWebUsbTransport = instance;
61
68
  if (usbDisconnectListenerAttached)
@@ -72,17 +79,26 @@ function registerActiveWebUsbTransport(instance, usb) {
72
79
  });
73
80
  }
74
81
  class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
75
- constructor() {
82
+ constructor(identityProbe = false) {
76
83
  super({
77
84
  router: transport.PROTOCOL_V2_CHANNEL_USB,
78
85
  maxFrameBytes: transport.PROTOCOL_V2_FRAME_MAX_BYTES,
79
86
  logPrefix: 'ProtocolV2 WebUSB',
80
87
  });
88
+ this.identityProbe = identityProbe;
89
+ this.transportInstanceId = ++nextWebUsbTransportInstanceId;
90
+ this.handleOperationSequence = 0;
91
+ this.deviceLogSequence = 0;
92
+ this.deviceLogIds = new WeakMap();
81
93
  this.deviceProtocol = new Map();
82
94
  this.confirmedDeviceProtocols = new Map();
83
95
  this.deviceProtocolHints = new Map();
84
96
  this.staleProtocolPaths = new Set();
85
97
  this.acquiredPaths = new Set();
98
+ this.identityProbeDevices = new Map();
99
+ this.normalDevices = new Map();
100
+ this.pendingHandleOperations = new Set();
101
+ this.identityProbeCleanupPaths = new Set();
86
102
  this.probedDeviceObjects = new Map();
87
103
  this.deviceEndpoints = new Map();
88
104
  this.name = 'WebUsbTransport';
@@ -101,7 +117,195 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
101
117
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'WebUSB is not supported by current browsers');
102
118
  }
103
119
  this.usb = usb;
104
- registerActiveWebUsbTransport(this, usb);
120
+ if (!this.identityProbe)
121
+ registerActiveWebUsbTransport(this, usb);
122
+ }
123
+ stop() {
124
+ const startedAt = Date.now();
125
+ this.stopped = true;
126
+ this.acquiredPaths.clear();
127
+ if (activeWebUsbTransport === this)
128
+ activeWebUsbTransport = undefined;
129
+ if (!this.stopPromise) {
130
+ this.logLifecycle('stop', { phase: 'start' });
131
+ this.stopPromise = this.stopResources().then(() => {
132
+ this.logLifecycle('stop', {
133
+ phase: 'success',
134
+ durationMs: Date.now() - startedAt,
135
+ });
136
+ }, error => {
137
+ this.logLifecycle('stop', Object.assign({ phase: 'error', durationMs: Date.now() - startedAt }, this.getDiagnosticError(error)));
138
+ this.stopPromise = undefined;
139
+ throw error;
140
+ });
141
+ }
142
+ return this.stopPromise;
143
+ }
144
+ stopResources() {
145
+ var _a, _b, _c;
146
+ return __awaiter(this, void 0, void 0, function* () {
147
+ let cleanupError;
148
+ try {
149
+ yield this.disposeProtocolV2UsbLinks('WebUSB transport stopped');
150
+ }
151
+ catch (error) {
152
+ cleanupError = error;
153
+ }
154
+ yield Promise.allSettled([...this.pendingHandleOperations]);
155
+ const handles = this.identityProbe
156
+ ? [...this.identityProbeDevices].map(([path, device]) => [device, path])
157
+ : [...this.normalDevices];
158
+ for (const [device, path] of handles) {
159
+ if (!this.identityProbe && normalHandleOwners.get(device) !== this) {
160
+ this.normalDevices.delete(device);
161
+ }
162
+ else {
163
+ try {
164
+ if (device.opened) {
165
+ const iface = (_b = (_a = this.deviceEndpoints.get(path)) === null || _a === void 0 ? void 0 : _a.interfaceNumber) !== null && _b !== void 0 ? _b : this.interfaceId;
166
+ if (this.getInterfaceClaimed(device, iface) === false) {
167
+ this.logSkippedHandleOperation('releaseInterface', device, 'interface-not-claimed', iface);
168
+ }
169
+ else {
170
+ try {
171
+ yield this.runLoggedHandleOperation({
172
+ operation: 'releaseInterface',
173
+ device,
174
+ path,
175
+ interfaceNumber: iface,
176
+ allowStopped: true,
177
+ task: () => device.releaseInterface(iface),
178
+ });
179
+ }
180
+ catch (error) {
181
+ (_c = this.Log) === null || _c === void 0 ? void 0 : _c.debug('[WebUsbTransport] stop releaseInterface failed:', error);
182
+ }
183
+ }
184
+ yield this.runLoggedHandleOperation({
185
+ operation: 'close',
186
+ device,
187
+ path,
188
+ allowStopped: true,
189
+ task: () => device.close(),
190
+ });
191
+ if (device.opened)
192
+ throw new Error('WebUSB handle remains open after stop');
193
+ }
194
+ if (this.identityProbe) {
195
+ identityProbeHandles.delete(device);
196
+ this.identityProbeDevices.delete(path);
197
+ this.identityProbeCleanupPaths.delete(path);
198
+ }
199
+ else {
200
+ normalHandleOwners.delete(device);
201
+ this.normalDevices.delete(device);
202
+ }
203
+ }
204
+ catch (error) {
205
+ cleanupError !== null && cleanupError !== void 0 ? cleanupError : (cleanupError = error);
206
+ }
207
+ }
208
+ }
209
+ this.deviceList = [];
210
+ this.deviceProtocol.clear();
211
+ this.confirmedDeviceProtocols.clear();
212
+ this.deviceProtocolHints.clear();
213
+ this.staleProtocolPaths.clear();
214
+ this.probedDeviceObjects.clear();
215
+ if (!this.normalDevices.size && !this.identityProbeDevices.size) {
216
+ this.deviceEndpoints.clear();
217
+ }
218
+ if (cleanupError) {
219
+ throw cleanupError instanceof Error
220
+ ? cleanupError
221
+ : new Error(this.getErrorMessage(cleanupError));
222
+ }
223
+ });
224
+ }
225
+ assertRunning() {
226
+ if (this.stopped) {
227
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'WebUSB transport is stopped');
228
+ }
229
+ }
230
+ runHandleOperation(operation) {
231
+ return __awaiter(this, void 0, void 0, function* () {
232
+ this.assertRunning();
233
+ const pending = Promise.resolve(operation());
234
+ this.pendingHandleOperations.add(pending);
235
+ try {
236
+ const result = yield pending;
237
+ this.assertRunning();
238
+ return result;
239
+ }
240
+ finally {
241
+ this.pendingHandleOperations.delete(pending);
242
+ }
243
+ });
244
+ }
245
+ getInterfaceClaimed(device, interfaceNumber) {
246
+ var _a, _b, _c;
247
+ return (_c = (_b = (_a = device.configuration) === null || _a === void 0 ? void 0 : _a.interfaces) === null || _b === void 0 ? void 0 : _b.find(usbInterface => usbInterface.interfaceNumber === interfaceNumber)) === null || _c === void 0 ? void 0 : _c.claimed;
248
+ }
249
+ getDeviceLogId(device) {
250
+ const existing = this.deviceLogIds.get(device);
251
+ if (existing)
252
+ return existing;
253
+ const next = ++this.deviceLogSequence;
254
+ this.deviceLogIds.set(device, next);
255
+ return next;
256
+ }
257
+ getHandleLogState(device, interfaceNumber) {
258
+ var _a, _b;
259
+ const interfaceClaimed = interfaceNumber === undefined ? undefined : this.getInterfaceClaimed(device, interfaceNumber);
260
+ return {
261
+ deviceRefId: this.getDeviceLogId(device),
262
+ opened: device.opened,
263
+ configurationValue: (_b = (_a = device.configuration) === null || _a === void 0 ? void 0 : _a.configurationValue) !== null && _b !== void 0 ? _b : null,
264
+ interfaceNumber: interfaceNumber !== null && interfaceNumber !== void 0 ? interfaceNumber : null,
265
+ interfaceClaimed: interfaceClaimed !== null && interfaceClaimed !== void 0 ? interfaceClaimed : null,
266
+ identityProbe: this.identityProbe,
267
+ stopped: this.stopped,
268
+ acquiredPathCount: this.acquiredPaths.size,
269
+ normalHandleCount: this.normalDevices.size,
270
+ identityProbeHandleCount: this.identityProbeDevices.size,
271
+ pendingHandleOperationCount: this.pendingHandleOperations.size,
272
+ };
273
+ }
274
+ getDiagnosticError(error, path) {
275
+ const errorMessage = this.getErrorMessage(error);
276
+ const redactedMessage = path ? errorMessage.split(path).join('<device>') : errorMessage;
277
+ return {
278
+ errorName: this.getErrorName(error) || 'Error',
279
+ errorMessage: redactedMessage.slice(0, WEBUSB_ERROR_MESSAGE_LIMIT),
280
+ errorCode: error instanceof hdShared.HardwareError ? error.errorCode : undefined,
281
+ };
282
+ }
283
+ logLifecycle(event, payload = {}) {
284
+ var _a;
285
+ (_a = this.Log) === null || _a === void 0 ? void 0 : _a.debug(WEBUSB_LIFECYCLE_LOG_PREFIX, Object.assign({ event, transportInstanceId: this.transportInstanceId }, payload));
286
+ }
287
+ logSkippedHandleOperation(operation, device, reason, interfaceNumber) {
288
+ const operationId = ++this.handleOperationSequence;
289
+ this.logLifecycle('handle-operation', Object.assign({ operationId,
290
+ operation, phase: 'skipped', reason }, this.getHandleLogState(device, interfaceNumber)));
291
+ }
292
+ runLoggedHandleOperation({ operation, device, task, path, interfaceNumber, endpointNumber, allowStopped = false, }) {
293
+ return __awaiter(this, void 0, void 0, function* () {
294
+ const operationId = ++this.handleOperationSequence;
295
+ const startedAt = Date.now();
296
+ const details = Object.assign({ operationId,
297
+ operation }, (endpointNumber === undefined ? {} : { endpointNumber }));
298
+ this.logLifecycle('handle-operation', Object.assign(Object.assign(Object.assign({}, details), { phase: 'start' }), this.getHandleLogState(device, interfaceNumber)));
299
+ try {
300
+ const result = allowStopped ? yield task() : yield this.runHandleOperation(task);
301
+ this.logLifecycle('handle-operation', Object.assign(Object.assign(Object.assign({}, details), { phase: 'success', durationMs: Date.now() - startedAt }), this.getHandleLogState(device, interfaceNumber)));
302
+ return result;
303
+ }
304
+ catch (error) {
305
+ this.logLifecycle('handle-operation', Object.assign(Object.assign(Object.assign(Object.assign({}, details), { phase: 'error', durationMs: Date.now() - startedAt }), this.getHandleLogState(device, interfaceNumber)), this.getDiagnosticError(error, path)));
306
+ throw error;
307
+ }
308
+ });
105
309
  }
106
310
  emitDeviceDisconnect(path, device) {
107
311
  var _a, _b;
@@ -138,11 +342,19 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
138
342
  return __awaiter(this, void 0, void 0, function* () {
139
343
  if (!this.usb)
140
344
  return null;
345
+ const startedAt = Date.now();
346
+ this.logLifecycle('prompt-device-access', { phase: 'start' });
141
347
  try {
142
348
  const device = yield this.usb.requestDevice({ filters: hdShared.ONEKEY_WEBUSB_FILTER });
349
+ this.logLifecycle('prompt-device-access', {
350
+ phase: 'success',
351
+ durationMs: Date.now() - startedAt,
352
+ selected: Boolean(device),
353
+ });
143
354
  return device;
144
355
  }
145
356
  catch (e) {
357
+ this.logLifecycle('prompt-device-access', Object.assign({ phase: 'error', durationMs: Date.now() - startedAt }, this.getDiagnosticError(e)));
146
358
  this.Log.debug('requestDevice error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
147
359
  return null;
148
360
  }
@@ -150,15 +362,66 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
150
362
  }
151
363
  enumerate() {
152
364
  return __awaiter(this, void 0, void 0, function* () {
153
- yield this.getConnectedDevices();
154
- return this.deviceList;
365
+ const startedAt = Date.now();
366
+ this.logLifecycle('enumerate', {
367
+ phase: 'start',
368
+ pendingProbeCleanupCount: this.identityProbeCleanupPaths.size,
369
+ });
370
+ try {
371
+ for (const path of this.identityProbeCleanupPaths) {
372
+ try {
373
+ yield this.release(path);
374
+ }
375
+ catch (_a) {
376
+ }
377
+ }
378
+ yield this.getConnectedDevices();
379
+ this.logLifecycle('enumerate', {
380
+ phase: 'success',
381
+ durationMs: Date.now() - startedAt,
382
+ deviceCount: this.deviceList.length,
383
+ });
384
+ return this.deviceList;
385
+ }
386
+ catch (error) {
387
+ this.logLifecycle('enumerate', Object.assign({ phase: 'error', durationMs: Date.now() - startedAt }, this.getDiagnosticError(error)));
388
+ throw error;
389
+ }
155
390
  });
156
391
  }
157
392
  getConnectedDevices() {
393
+ var _a;
158
394
  return __awaiter(this, void 0, void 0, function* () {
395
+ this.assertRunning();
159
396
  if (!this.usb)
160
397
  return [];
161
- const devices = yield this.usb.getDevices();
398
+ const startedAt = Date.now();
399
+ this.logLifecycle('get-devices', { phase: 'start' });
400
+ let devices;
401
+ try {
402
+ devices = yield this.usb.getDevices();
403
+ this.logLifecycle('get-devices', {
404
+ phase: 'success',
405
+ durationMs: Date.now() - startedAt,
406
+ authorizedDeviceCount: devices.length,
407
+ });
408
+ }
409
+ catch (error) {
410
+ this.logLifecycle('get-devices', Object.assign({ phase: 'error', durationMs: Date.now() - startedAt }, this.getDiagnosticError(error)));
411
+ throw error;
412
+ }
413
+ this.assertRunning();
414
+ for (const owner of new Set(devices.map(device => normalHandleOwners.get(device)))) {
415
+ if (owner === null || owner === void 0 ? void 0 : owner.stopped) {
416
+ try {
417
+ yield owner.stop();
418
+ }
419
+ catch (error) {
420
+ (_a = this.Log) === null || _a === void 0 ? void 0 : _a.debug('[WebUsbTransport] stopped owner cleanup failed:', error);
421
+ }
422
+ }
423
+ }
424
+ this.assertRunning();
162
425
  const onekeyDevices = devices.filter(dev => {
163
426
  const isOneKey = hdShared.ONEKEY_WEBUSB_FILTER.some((desc) => dev.vendorId === desc.vendorId && dev.productId === desc.productId);
164
427
  return isOneKey && !hdShared.isKnownTrezorWebUsbDevice(dev);
@@ -183,14 +446,34 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
183
446
  });
184
447
  }
185
448
  acquire(input) {
186
- var _a, _b, _c, _d, _e, _f, _g;
449
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
187
450
  return __awaiter(this, void 0, void 0, function* () {
451
+ this.assertRunning();
188
452
  if (!input.path)
189
453
  return;
454
+ const startedAt = Date.now();
455
+ this.logLifecycle('acquire', {
456
+ phase: 'start',
457
+ expectedProtocol: (_a = input.expectedProtocol) !== null && _a !== void 0 ? _a : null,
458
+ protocolHint: (_b = input.protocolHint) !== null && _b !== void 0 ? _b : null,
459
+ forceProtocolDetection: Boolean(input.forceProtocolDetection),
460
+ skipProtocolProbe: Boolean(input.skipProtocolProbe),
461
+ hasCachedProtocol: this.deviceProtocol.has(input.path),
462
+ });
190
463
  try {
464
+ if (this.identityProbe) {
465
+ yield this.getConnectedDevices();
466
+ this.claimIdentityProbeHandle(input.path, yield this.findDevice(input.path));
467
+ }
468
+ else {
469
+ this.claimNormalHandle(input.path, yield this.findDevice(input.path));
470
+ }
191
471
  yield this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
192
472
  yield this.closeOpenDevice(input.path);
193
- yield this.connect((_a = input.path) !== null && _a !== void 0 ? _a : '', true);
473
+ yield this.connect((_c = input.path) !== null && _c !== void 0 ? _c : '', true, {
474
+ expectedProtocol: input.expectedProtocol,
475
+ forceProtocolDetection: input.forceProtocolDetection,
476
+ });
194
477
  if (input.skipProtocolProbe) {
195
478
  if (!input.expectedProtocol) {
196
479
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, 'skipProtocolProbe requires an expected protocol');
@@ -200,7 +483,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
200
483
  }
201
484
  this.staleProtocolPaths.delete(input.path);
202
485
  this.deviceProtocol.set(input.path, input.expectedProtocol);
203
- const currentDevice = (_b = this.deviceList.find(d => d.path === input.path)) === null || _b === void 0 ? void 0 : _b.device;
486
+ const currentDevice = (_d = this.deviceList.find(d => d.path === input.path)) === null || _d === void 0 ? void 0 : _d.device;
204
487
  if (currentDevice) {
205
488
  this.probedDeviceObjects.set(input.path, currentDevice);
206
489
  }
@@ -215,7 +498,7 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
215
498
  this.deviceProtocol.delete(input.path);
216
499
  }
217
500
  if (!input.skipProtocolProbe && this.deviceProtocol.has(input.path)) {
218
- const currentDevice = (_c = this.deviceList.find(d => d.path === input.path)) === null || _c === void 0 ? void 0 : _c.device;
501
+ const currentDevice = (_e = this.deviceList.find(d => d.path === input.path)) === null || _e === void 0 ? void 0 : _e.device;
219
502
  if (!currentDevice || currentDevice !== this.probedDeviceObjects.get(input.path)) {
220
503
  this.deviceProtocol.delete(input.path);
221
504
  }
@@ -228,39 +511,61 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
228
511
  this.deviceProtocol.delete(input.path);
229
512
  }
230
513
  if (!this.deviceProtocol.has(input.path)) {
231
- const usbDevice = (_d = this.deviceList.find(device => device.path === input.path)) === null || _d === void 0 ? void 0 : _d.device;
514
+ const usbDevice = (_f = this.deviceList.find(device => device.path === input.path)) === null || _f === void 0 ? void 0 : _f.device;
232
515
  const protocolHint = input.expectedProtocol
233
516
  ? undefined
234
- : (_f = (_e = input.protocolHint) !== null && _e !== void 0 ? _e : this.deviceProtocolHints.get(input.path)) !== null && _f !== void 0 ? _f : hdShared.inferProtocolHintFromUsbId(usbDevice === null || usbDevice === void 0 ? void 0 : usbDevice.vendorId, usbDevice === null || usbDevice === void 0 ? void 0 : usbDevice.productId);
517
+ : (_h = (_g = input.protocolHint) !== null && _g !== void 0 ? _g : this.deviceProtocolHints.get(input.path)) !== null && _h !== void 0 ? _h : hdShared.inferProtocolHintFromUsbId(usbDevice === null || usbDevice === void 0 ? void 0 : usbDevice.vendorId, usbDevice === null || usbDevice === void 0 ? void 0 : usbDevice.productId);
235
518
  if (protocolHint) {
236
519
  this.deviceProtocolHints.set(input.path, protocolHint);
237
520
  }
238
521
  const detectedProtocol = yield this.detectProtocol(input.path, input.expectedProtocol, protocolHint);
522
+ this.assertRunning();
239
523
  this.confirmedDeviceProtocols.set(input.path, detectedProtocol);
240
- const probedDevice = (_g = this.deviceList.find(d => d.path === input.path)) === null || _g === void 0 ? void 0 : _g.device;
524
+ const probedDevice = (_j = this.deviceList.find(d => d.path === input.path)) === null || _j === void 0 ? void 0 : _j.device;
241
525
  if (probedDevice) {
242
526
  this.probedDeviceObjects.set(input.path, probedDevice);
243
527
  }
244
528
  }
529
+ this.assertRunning();
245
530
  this.acquiredPaths.add(input.path);
531
+ this.logLifecycle('acquire', {
532
+ phase: 'success',
533
+ durationMs: Date.now() - startedAt,
534
+ protocol: (_k = this.deviceProtocol.get(input.path)) !== null && _k !== void 0 ? _k : null,
535
+ });
246
536
  return yield Promise.resolve(input.path);
247
537
  }
248
538
  catch (e) {
539
+ this.logLifecycle('acquire', Object.assign({ phase: 'error', durationMs: Date.now() - startedAt }, this.getDiagnosticError(e, input.path)));
249
540
  this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
250
541
  this.acquiredPaths.delete(input.path);
251
- yield this.closeOpenDevice(input.path);
542
+ try {
543
+ yield this.closeOpenDevice(input.path);
544
+ }
545
+ catch (cleanupError) {
546
+ this.logLifecycle('acquire-cleanup', Object.assign({ phase: 'error' }, this.getDiagnosticError(cleanupError, input.path)));
547
+ }
548
+ finally {
549
+ this.releaseIdentityProbeHandle(input.path);
550
+ this.releaseNormalHandles(input.path);
551
+ }
252
552
  throw e;
253
553
  }
254
554
  });
255
555
  }
256
556
  createProtocolMismatchError(expected) {
257
- return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`);
557
+ return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
558
+ expectedProtocol: expected,
559
+ });
258
560
  }
259
561
  createProtocolProbeTimeoutError(expected, attempts) {
260
- return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.DeviceInitializeFailed, `Protocol ${expected} probe timeout after ${attempts} attempts`);
562
+ return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
563
+ expectedProtocol: expected,
564
+ attempts,
565
+ });
261
566
  }
262
567
  createProtocolDetectionError() {
263
- return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.DeviceNotFound);
568
+ return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.TransportProtocolProbeFailed);
264
569
  }
265
570
  detectProtocol(path, expectedProtocol, protocolHint) {
266
571
  var _a;
@@ -324,18 +629,48 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
324
629
  return device.device;
325
630
  });
326
631
  }
327
- connect(path, first) {
632
+ connect(path, first, options = {}) {
328
633
  return __awaiter(this, void 0, void 0, function* () {
329
634
  const maxRetries = 5;
330
635
  for (let i = 0; i < maxRetries; i++) {
636
+ this.assertRunning();
637
+ const attempt = i + 1;
638
+ const startedAt = Date.now();
639
+ const connectionStrategy = this.shouldUseLegacyV1Connection(path, options)
640
+ ? 'protocol-v1-legacy-1.1.31'
641
+ : 'current';
642
+ this.logLifecycle('connect-attempt', {
643
+ phase: 'start',
644
+ attempt,
645
+ maxAttempts: maxRetries,
646
+ first,
647
+ connectionStrategy,
648
+ });
331
649
  try {
332
- return yield this.connectToDevice(path, first);
650
+ const result = yield this.connectToDevice(path, first, options);
651
+ this.logLifecycle('connect-attempt', {
652
+ phase: 'success',
653
+ attempt,
654
+ maxAttempts: maxRetries,
655
+ first,
656
+ connectionStrategy,
657
+ durationMs: Date.now() - startedAt,
658
+ });
659
+ return result;
333
660
  }
334
661
  catch (e) {
335
- if (e instanceof hdShared.HardwareError && e.errorCode === hdShared.HardwareErrorCode.BridgeNeedsPermission) {
662
+ this.assertRunning();
663
+ const isTerminalError = this.isHandleOwnershipError(e) ||
664
+ (e instanceof hdShared.HardwareError &&
665
+ (e.errorCode === hdShared.HardwareErrorCode.BridgeNeedsPermission ||
666
+ e.errorCode === hdShared.HardwareErrorCode.WebUsbDeviceAccessError));
667
+ const willRetry = !isTerminalError && attempt < maxRetries;
668
+ this.logLifecycle('connect-attempt', Object.assign({ phase: 'error', attempt, maxAttempts: maxRetries, first,
669
+ connectionStrategy, durationMs: Date.now() - startedAt, willRetry }, this.getDiagnosticError(e, path)));
670
+ if (isTerminalError) {
336
671
  throw e;
337
672
  }
338
- if (i === maxRetries - 1) {
673
+ if (!willRetry) {
339
674
  throw e;
340
675
  }
341
676
  yield hdShared.wait(i * 200);
@@ -343,6 +678,11 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
343
678
  }
344
679
  });
345
680
  }
681
+ shouldUseLegacyV1Connection(path, options) {
682
+ if (options.expectedProtocol)
683
+ return options.expectedProtocol === 'V1';
684
+ return !options.forceProtocolDetection && this.deviceProtocol.get(path) === 'V1';
685
+ }
346
686
  discoverEndpoints(device) {
347
687
  var _a, _b;
348
688
  for (const config of device.configurations) {
@@ -371,87 +711,276 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
371
711
  endpointOut: this.endpointId,
372
712
  };
373
713
  }
374
- connectToDevice(path, first) {
375
- var _a;
714
+ connectToDevice(path, first, options = {}) {
376
715
  return __awaiter(this, void 0, void 0, function* () {
716
+ this.assertRunning();
377
717
  let device = yield this.findDevice(path);
718
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
719
+ yield this.closeOpenDevice(path);
720
+ this.releaseIdentityProbeHandle(path);
721
+ this.claimIdentityProbeHandle(path, device);
722
+ }
723
+ if (!this.identityProbe)
724
+ this.claimNormalHandle(path, device);
725
+ this.assertHandleAvailable(device);
378
726
  if (!device.opened) {
727
+ openingHandles.add(device);
379
728
  try {
380
- yield device.open();
729
+ yield this.runLoggedHandleOperation({
730
+ operation: 'open',
731
+ device,
732
+ path,
733
+ task: () => device.open(),
734
+ });
381
735
  }
382
736
  catch (error) {
383
- return this.throwWebUsbIoError(path, 'open', error);
737
+ return yield this.throwWebUsbIoError(path, 'open', error);
738
+ }
739
+ finally {
740
+ openingHandles.delete(device);
741
+ }
742
+ }
743
+ if (this.shouldUseLegacyV1Connection(path, options)) {
744
+ if (first ||
745
+ !device.configuration ||
746
+ device.configuration.configurationValue !== this.configurationId) {
747
+ yield this.runLoggedHandleOperation({
748
+ operation: 'selectConfiguration',
749
+ device,
750
+ path,
751
+ task: () => device.selectConfiguration(this.configurationId),
752
+ });
753
+ }
754
+ if (first) {
755
+ try {
756
+ yield this.runLoggedHandleOperation({
757
+ operation: 'reset',
758
+ device,
759
+ path,
760
+ task: () => device.reset(),
761
+ });
762
+ }
763
+ catch (_a) {
764
+ this.assertRunning();
765
+ }
384
766
  }
767
+ const legacyEndpoints = this.discoverEndpoints(device);
768
+ this.deviceEndpoints.set(path, legacyEndpoints);
769
+ yield this.claimDeviceInterface(path, device, legacyEndpoints);
770
+ return;
385
771
  }
386
772
  if (first || this.deviceProtocol.get(path) !== 'V1') {
387
773
  try {
388
- yield device.reset();
774
+ yield this.runLoggedHandleOperation({
775
+ operation: 'reset',
776
+ device,
777
+ path,
778
+ task: () => device.reset(),
779
+ });
389
780
  }
390
- catch (error) {
391
- (_a = this.Log) === null || _a === void 0 ? void 0 : _a.debug('[WebUsbTransport] reset before claim failed, continuing:', error);
781
+ catch (_b) {
782
+ this.assertRunning();
392
783
  }
393
784
  }
394
785
  yield this.getConnectedDevices();
395
786
  device = yield this.findDevice(path);
787
+ if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
788
+ yield this.closeOpenDevice(path);
789
+ this.releaseIdentityProbeHandle(path);
790
+ this.claimIdentityProbeHandle(path, device);
791
+ }
792
+ if (!this.identityProbe)
793
+ this.claimNormalHandle(path, device);
794
+ this.assertHandleAvailable(device);
396
795
  if (!device.opened) {
796
+ openingHandles.add(device);
397
797
  try {
398
- yield device.open();
798
+ yield this.runLoggedHandleOperation({
799
+ operation: 'open',
800
+ device,
801
+ path,
802
+ task: () => device.open(),
803
+ });
399
804
  }
400
805
  catch (error) {
401
- return this.throwWebUsbIoError(path, 'open', error);
806
+ return yield this.throwWebUsbIoError(path, 'open', error);
807
+ }
808
+ finally {
809
+ openingHandles.delete(device);
402
810
  }
403
811
  }
404
812
  if (first ||
405
813
  !device.configuration ||
406
814
  device.configuration.configurationValue !== this.configurationId) {
407
- yield device.selectConfiguration(this.configurationId);
815
+ yield this.runLoggedHandleOperation({
816
+ operation: 'selectConfiguration',
817
+ device,
818
+ path,
819
+ task: () => device.selectConfiguration(this.configurationId),
820
+ });
408
821
  }
409
822
  const endpoints = this.discoverEndpoints(device);
410
823
  this.deviceEndpoints.set(path, endpoints);
824
+ yield this.claimDeviceInterface(path, device, endpoints);
825
+ yield this.clearEndpointHalt(path, device, 'in', endpoints.endpointIn);
826
+ yield this.clearEndpointHalt(path, device, 'out', endpoints.endpointOut);
827
+ });
828
+ }
829
+ claimDeviceInterface(path, device, endpoints) {
830
+ return __awaiter(this, void 0, void 0, function* () {
411
831
  try {
412
- yield device.claimInterface(endpoints.interfaceNumber);
832
+ yield this.runLoggedHandleOperation({
833
+ operation: 'claimInterface',
834
+ device,
835
+ path,
836
+ interfaceNumber: endpoints.interfaceNumber,
837
+ task: () => device.claimInterface(endpoints.interfaceNumber),
838
+ });
413
839
  }
414
840
  catch (error) {
415
841
  return this.throwWebUsbIoError(path, 'claimInterface', error);
416
842
  }
417
- yield this.clearEndpointHalt(device, 'in', endpoints.endpointIn);
418
- yield this.clearEndpointHalt(device, 'out', endpoints.endpointOut);
419
843
  });
420
844
  }
845
+ assertHandleAvailable(device) {
846
+ this.assertRunning();
847
+ const normalOwner = normalHandleOwners.get(device);
848
+ if ((normalOwner && normalOwner !== this) ||
849
+ (identityProbeHandles.has(device) &&
850
+ ![...this.identityProbeDevices.values()].includes(device))) {
851
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
852
+ }
853
+ }
854
+ claimNormalHandle(path, device) {
855
+ this.assertHandleAvailable(device);
856
+ normalHandleOwners.set(device, this);
857
+ this.normalDevices.set(device, path);
858
+ }
859
+ releaseNormalHandles(path) {
860
+ if (this.stopped)
861
+ return;
862
+ for (const [device, ownedPath] of this.normalDevices) {
863
+ if (ownedPath === path && !device.opened) {
864
+ normalHandleOwners.delete(device);
865
+ this.normalDevices.delete(device);
866
+ }
867
+ }
868
+ }
869
+ isHandleOwnershipError(error) {
870
+ return (error instanceof hdShared.HardwareError &&
871
+ error.errorCode === hdShared.HardwareErrorCode.RuntimeError &&
872
+ this.getErrorMessage(error).includes(USB_DEVICE_IN_USE));
873
+ }
874
+ claimIdentityProbeHandle(path, device) {
875
+ this.assertRunning();
876
+ if (device.opened ||
877
+ openingHandles.has(device) ||
878
+ identityProbeHandles.has(device) ||
879
+ normalHandleOwners.has(device)) {
880
+ throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
881
+ }
882
+ identityProbeHandles.add(device);
883
+ this.identityProbeDevices.set(path, device);
884
+ }
885
+ releaseIdentityProbeHandle(path) {
886
+ if (this.stopped)
887
+ return;
888
+ const device = this.identityProbeDevices.get(path);
889
+ if (device === null || device === void 0 ? void 0 : device.opened) {
890
+ this.identityProbeCleanupPaths.add(path);
891
+ return;
892
+ }
893
+ if (device)
894
+ identityProbeHandles.delete(device);
895
+ this.identityProbeDevices.delete(path);
896
+ this.identityProbeCleanupPaths.delete(path);
897
+ }
421
898
  closeOpenDevice(path) {
422
- var _a, _b, _c, _d;
423
899
  return __awaiter(this, void 0, void 0, function* () {
424
- const current = (_a = this.deviceList.find(device => device.path === path)) === null || _a === void 0 ? void 0 : _a.device;
425
- if (!(current === null || current === void 0 ? void 0 : current.opened))
900
+ if (this.stopped)
426
901
  return;
902
+ const devices = this.identityProbe
903
+ ? [this.identityProbeDevices.get(path)].filter((device) => device !== undefined)
904
+ : [...this.normalDevices]
905
+ .filter(([, ownedPath]) => ownedPath === path)
906
+ .map(([device]) => device);
907
+ let firstCloseError;
908
+ for (const device of devices) {
909
+ try {
910
+ yield this.closeDeviceHandle(path, device);
911
+ }
912
+ catch (error) {
913
+ firstCloseError !== null && firstCloseError !== void 0 ? firstCloseError : (firstCloseError = error);
914
+ }
915
+ }
916
+ if (firstCloseError) {
917
+ throw firstCloseError instanceof Error
918
+ ? firstCloseError
919
+ : new Error(this.getErrorMessage(firstCloseError));
920
+ }
921
+ });
922
+ }
923
+ closeDeviceHandle(path, current) {
924
+ var _a, _b, _c;
925
+ return __awaiter(this, void 0, void 0, function* () {
926
+ if (!current.opened)
927
+ return;
928
+ this.assertHandleAvailable(current);
427
929
  const endpoints = this.deviceEndpoints.get(path);
428
- const ifaceNum = (_b = endpoints === null || endpoints === void 0 ? void 0 : endpoints.interfaceNumber) !== null && _b !== void 0 ? _b : this.interfaceId;
429
- try {
430
- yield current.releaseInterface(ifaceNum);
930
+ const ifaceNum = (_a = endpoints === null || endpoints === void 0 ? void 0 : endpoints.interfaceNumber) !== null && _a !== void 0 ? _a : this.interfaceId;
931
+ if (this.getInterfaceClaimed(current, ifaceNum) === false) {
932
+ this.logSkippedHandleOperation('releaseInterface', current, 'interface-not-claimed', ifaceNum);
431
933
  }
432
- catch (error) {
433
- (_c = this.Log) === null || _c === void 0 ? void 0 : _c.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
934
+ else {
935
+ try {
936
+ yield this.runLoggedHandleOperation({
937
+ operation: 'releaseInterface',
938
+ device: current,
939
+ path,
940
+ interfaceNumber: ifaceNum,
941
+ task: () => current.releaseInterface(ifaceNum),
942
+ });
943
+ }
944
+ catch (error) {
945
+ (_b = this.Log) === null || _b === void 0 ? void 0 : _b.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
946
+ }
434
947
  }
948
+ this.assertHandleAvailable(current);
435
949
  try {
436
- yield current.close();
950
+ yield this.runLoggedHandleOperation({
951
+ operation: 'close',
952
+ device: current,
953
+ path,
954
+ task: () => current.close(),
955
+ });
437
956
  }
438
957
  catch (error) {
439
- (_d = this.Log) === null || _d === void 0 ? void 0 : _d.debug('[WebUsbTransport] close before reconnect failed:', error);
958
+ (_c = this.Log) === null || _c === void 0 ? void 0 : _c.debug('[WebUsbTransport] close before reconnect failed:', error);
959
+ throw error;
440
960
  }
441
961
  });
442
962
  }
443
- clearEndpointHalt(device, direction, endpointNumber) {
963
+ clearEndpointHalt(path, device, direction, endpointNumber) {
444
964
  var _a;
445
965
  return __awaiter(this, void 0, void 0, function* () {
966
+ this.assertHandleAvailable(device);
446
967
  try {
447
- yield device.clearHalt(direction, endpointNumber);
968
+ yield this.runLoggedHandleOperation({
969
+ operation: direction === 'in' ? 'clearHaltIn' : 'clearHaltOut',
970
+ device,
971
+ path,
972
+ endpointNumber,
973
+ task: () => device.clearHalt(direction, endpointNumber),
974
+ });
448
975
  }
449
976
  catch (error) {
977
+ this.assertRunning();
450
978
  (_a = this.Log) === null || _a === void 0 ? void 0 : _a.debug(`[WebUsbTransport] clearHalt ${direction} endpoint ${endpointNumber} failed, continuing:`, error);
451
979
  }
452
980
  });
453
981
  }
454
982
  assertAcquired(path) {
983
+ this.assertRunning();
455
984
  if (!this.acquiredPaths.has(path)) {
456
985
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, `Device is not acquired for ${path}`);
457
986
  }
@@ -508,7 +1037,8 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
508
1037
  throwWebUsbIoError(path, operation, error) {
509
1038
  var _a;
510
1039
  return __awaiter(this, void 0, void 0, function* () {
511
- if (this.isWebUsbIoHardwareError(error))
1040
+ this.assertRunning();
1041
+ if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error))
512
1042
  throw error;
513
1043
  if (error instanceof transport.ProtocolV2LinkError && error.code !== 'io')
514
1044
  throw error;
@@ -542,7 +1072,9 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
542
1072
  return /LIBUSB_ERROR_ACCESS|\b(?:access|permission) denied\b/i.test(message);
543
1073
  }
544
1074
  isRetryablePacketIoError(error) {
545
- if (this.isWebUsbIoHardwareError(error))
1075
+ if (this.stopped)
1076
+ return false;
1077
+ if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error))
546
1078
  return false;
547
1079
  const message = this.getErrorMessage(error).toLowerCase();
548
1080
  if (this.isWebUsbPermissionDeniedMessage(message))
@@ -561,21 +1093,43 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
561
1093
  this.Log.debug(`[WebUsbTransport] transfer${direction} failed, retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(error)}`);
562
1094
  this.staleProtocolPaths.add(path);
563
1095
  yield hdShared.wait(attempt * PACKET_IO_RETRY_DELAY);
1096
+ this.assertRunning();
564
1097
  try {
565
1098
  const currentDevice = yield this.findDevice(path);
1099
+ this.assertHandleAvailable(currentDevice);
566
1100
  if (currentDevice.opened) {
567
1101
  const endpoints = this.deviceEndpoints.get(path);
568
1102
  const ifaceNum = (_a = endpoints === null || endpoints === void 0 ? void 0 : endpoints.interfaceNumber) !== null && _a !== void 0 ? _a : this.interfaceId;
569
- try {
570
- yield currentDevice.releaseInterface(ifaceNum);
1103
+ if (this.getInterfaceClaimed(currentDevice, ifaceNum) === false) {
1104
+ this.logSkippedHandleOperation('releaseInterface', currentDevice, 'interface-not-claimed', ifaceNum);
571
1105
  }
572
- catch (releaseError) {
573
- this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
1106
+ else {
1107
+ try {
1108
+ yield this.runLoggedHandleOperation({
1109
+ operation: 'releaseInterface',
1110
+ device: currentDevice,
1111
+ path,
1112
+ interfaceNumber: ifaceNum,
1113
+ task: () => currentDevice.releaseInterface(ifaceNum),
1114
+ });
1115
+ }
1116
+ catch (releaseError) {
1117
+ this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
1118
+ }
574
1119
  }
575
- yield currentDevice.close();
1120
+ this.assertHandleAvailable(currentDevice);
1121
+ yield this.runLoggedHandleOperation({
1122
+ operation: 'close',
1123
+ device: currentDevice,
1124
+ path,
1125
+ task: () => currentDevice.close(),
1126
+ });
576
1127
  }
577
1128
  }
578
1129
  catch (closeError) {
1130
+ this.assertRunning();
1131
+ if (this.isHandleOwnershipError(closeError))
1132
+ throw closeError;
579
1133
  this.Log.debug('[WebUsbTransport] close device before retry error:', closeError);
580
1134
  }
581
1135
  yield this.getConnectedDevices();
@@ -617,6 +1171,9 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
617
1171
  yield this.reconnectForPacketIoRetry(path, 'out', attempt, error);
618
1172
  }
619
1173
  catch (reconnectError) {
1174
+ this.assertRunning();
1175
+ if (this.isHandleOwnershipError(reconnectError))
1176
+ throw reconnectError;
620
1177
  lastError = reconnectError;
621
1178
  this.Log.debug(`[WebUsbTransport] transferout reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(reconnectError)}`);
622
1179
  }
@@ -635,7 +1192,9 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
635
1192
  const endpoints = this.deviceEndpoints.get(path);
636
1193
  const endpointOut = (_a = endpoints === null || endpoints === void 0 ? void 0 : endpoints.endpointOut) !== null && _a !== void 0 ? _a : this.endpointId;
637
1194
  const transferBuffer = this.toArrayBuffer(packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength));
1195
+ this.assertHandleAvailable(device);
638
1196
  const result = yield device.transferOut(endpointOut, transferBuffer);
1197
+ this.assertHandleAvailable(device);
639
1198
  if (result.status !== 'ok') {
640
1199
  return this.throwWebUsbIoError(path, 'transferOut', new Error(`transferOut status: ${String(result.status)}`));
641
1200
  }
@@ -656,7 +1215,9 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
656
1215
  }
657
1216
  const endpoints = this.deviceEndpoints.get(path);
658
1217
  const endpointIn = (_a = endpoints === null || endpoints === void 0 ? void 0 : endpoints.endpointIn) !== null && _a !== void 0 ? _a : this.endpointId;
1218
+ this.assertHandleAvailable(device);
659
1219
  const result = yield device.transferIn(endpointIn, length);
1220
+ this.assertHandleAvailable(device);
660
1221
  return this.getTransferInData(result);
661
1222
  }
662
1223
  catch (error) {
@@ -672,6 +1233,9 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
672
1233
  yield this.reconnectForPacketIoRetry(path, 'in', attempt, error);
673
1234
  }
674
1235
  catch (reconnectError) {
1236
+ this.assertRunning();
1237
+ if (this.isHandleOwnershipError(reconnectError))
1238
+ throw reconnectError;
675
1239
  lastError = reconnectError;
676
1240
  this.Log.debug(`[WebUsbTransport] transferin reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(reconnectError)}`);
677
1241
  }
@@ -689,7 +1253,9 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
689
1253
  }
690
1254
  const endpoints = this.deviceEndpoints.get(path);
691
1255
  const endpointIn = (_a = endpoints === null || endpoints === void 0 ? void 0 : endpoints.endpointIn) !== null && _a !== void 0 ? _a : this.endpointId;
1256
+ this.assertHandleAvailable(device);
692
1257
  const result = yield device.transferIn(endpointIn, length);
1258
+ this.assertHandleAvailable(device);
693
1259
  return this.getTransferInData(result);
694
1260
  });
695
1261
  }
@@ -749,10 +1315,10 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
749
1315
  return false;
750
1316
  }
751
1317
  try {
752
- yield this.callProtocolV1(path, 'Initialize', {}, {
1318
+ const response = yield this.callProtocolV1(path, this.identityProbe ? 'GetFeatures' : 'Initialize', {}, {
753
1319
  timeoutMs: PROTOCOL_V1_PROBE_TIMEOUT,
754
1320
  });
755
- return true;
1321
+ return !this.identityProbe || response.type === 'Features';
756
1322
  }
757
1323
  catch (error) {
758
1324
  if (this.isWebUsbIoHardwareError(error))
@@ -868,9 +1434,25 @@ class WebUsbTransport extends transport.ProtocolV2UsbTransportBase {
868
1434
  }
869
1435
  release(path) {
870
1436
  return __awaiter(this, void 0, void 0, function* () {
1437
+ const startedAt = Date.now();
1438
+ this.logLifecycle('release', { phase: 'start' });
871
1439
  this.acquiredPaths.delete(path);
872
- yield this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
873
- yield this.closeOpenDevice(path);
1440
+ try {
1441
+ yield this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
1442
+ yield this.closeOpenDevice(path);
1443
+ this.logLifecycle('release', {
1444
+ phase: 'success',
1445
+ durationMs: Date.now() - startedAt,
1446
+ });
1447
+ }
1448
+ catch (error) {
1449
+ this.logLifecycle('release', Object.assign({ phase: 'error', durationMs: Date.now() - startedAt }, this.getDiagnosticError(error, path)));
1450
+ throw error;
1451
+ }
1452
+ finally {
1453
+ this.releaseIdentityProbeHandle(path);
1454
+ this.releaseNormalHandles(path);
1455
+ }
874
1456
  this.deviceEndpoints.delete(path);
875
1457
  });
876
1458
  }
@@ -924,7 +1506,7 @@ function resolveBlePacketCapacity(mtu, maximumPacketCapacity, fallbackPacketCapa
924
1506
  }
925
1507
 
926
1508
  const { parseConfigure, ProtocolV1, check } = transport__default["default"];
927
- const toBleDescriptor = (device, protocolType) => (Object.assign({ id: device.id, name: device.name, path: device.id, debug: false, commType: 'electron-ble' }, (protocolType ? { protocolType } : {})));
1509
+ const toBleDescriptor = (device, protocolType) => (Object.assign(Object.assign({ id: device.id, name: device.name, path: device.id, debug: false, commType: 'electron-ble' }, (device.bleColorCode ? { bleColorCode: device.bleColorCode } : {})), (protocolType ? { protocolType } : {})));
928
1510
  const invokeNobleBle = (request) => __awaiter(void 0, void 0, void 0, function* () {
929
1511
  const response = yield request;
930
1512
  if (response &&
@@ -950,6 +1532,8 @@ class ElectronBleTransport {
950
1532
  this.runPromise = null;
951
1533
  this.runPromiseDeviceId = null;
952
1534
  this.connectedDevices = new Set();
1535
+ this.retainedDevices = new Set();
1536
+ this.protocolV2CallCounts = new Map();
953
1537
  this.acquiringDevices = new Set();
954
1538
  this.deviceProtocol = new Map();
955
1539
  this.deviceProtocolHints = new Map();
@@ -1029,6 +1613,7 @@ class ElectronBleTransport {
1029
1613
  .invalidateLink(deviceId, 'Electron BLE device state cleaned')
1030
1614
  .catch(error => { var _a; return (_a = this.Log) === null || _a === void 0 ? void 0 : _a.debug('[Electron BLE] link cleanup failed:', error); });
1031
1615
  this.connectedDevices.delete(deviceId);
1616
+ this.retainedDevices.delete(deviceId);
1032
1617
  this.deviceProtocol.delete(deviceId);
1033
1618
  this.deviceMtus.delete(deviceId);
1034
1619
  this.devicePacketCapacities.delete(deviceId);
@@ -1125,11 +1710,18 @@ class ElectronBleTransport {
1125
1710
  acquire(input) {
1126
1711
  var _a, _b, _c, _d, _e, _f, _g;
1127
1712
  return __awaiter(this, void 0, void 0, function* () {
1128
- const { uuid, forceCleanRunPromise, expectedProtocol } = input;
1713
+ const { uuid, forceCleanRunPromise, expectedProtocol, forceProtocolDetection } = input;
1129
1714
  if (!uuid) {
1130
1715
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleRequiredUUID);
1131
1716
  }
1132
- if (this.connectedDevices.has(uuid)) {
1717
+ const retainedToken = this.retainedDevices.has(uuid) &&
1718
+ !forceProtocolDetection &&
1719
+ (!expectedProtocol || expectedProtocol === 'V2') &&
1720
+ this.deviceProtocol.get(uuid) === 'V2'
1721
+ ? this.notificationTokens.get(uuid)
1722
+ : undefined;
1723
+ this.retainedDevices.delete(uuid);
1724
+ if (this.connectedDevices.has(uuid) && retainedToken === undefined) {
1133
1725
  yield this.release(uuid);
1134
1726
  }
1135
1727
  if (forceCleanRunPromise && this.runPromise) {
@@ -1147,6 +1739,12 @@ class ElectronBleTransport {
1147
1739
  if (!device) {
1148
1740
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.DeviceNotFound, `Device ${uuid} not found`);
1149
1741
  }
1742
+ if (retainedToken !== undefined &&
1743
+ this.notificationTokens.get(uuid) === retainedToken &&
1744
+ this.connectedDevices.has(uuid) &&
1745
+ this.deviceProtocol.get(uuid) === 'V2') {
1746
+ return Object.assign(Object.assign({}, toBleDescriptor({ id: device.id, name: device.name }, 'V2')), { uuid });
1747
+ }
1150
1748
  const protocolHint = expectedProtocol
1151
1749
  ? undefined
1152
1750
  : (_b = input.protocolHint) !== null && _b !== void 0 ? _b : this.deviceProtocolHints.get(uuid);
@@ -1210,12 +1808,19 @@ class ElectronBleTransport {
1210
1808
  }
1211
1809
  });
1212
1810
  }
1213
- release(id, _onclose, keepSession) {
1811
+ release(id, onclose, keepSession) {
1214
1812
  var _a;
1215
1813
  return __awaiter(this, void 0, void 0, function* () {
1216
1814
  try {
1217
- yield this.protocolV2Links.invalidateLink(id, 'Electron BLE transport released');
1218
- yield this.releaseLogical(id, keepSession);
1815
+ const retainLink = keepSession === true &&
1816
+ !onclose &&
1817
+ this.connectedDevices.has(id) &&
1818
+ this.deviceProtocol.get(id) === 'V2' &&
1819
+ !this.protocolV2CallCounts.has(id);
1820
+ if (!retainLink) {
1821
+ yield this.protocolV2Links.invalidateLink(id, 'Electron BLE transport released');
1822
+ }
1823
+ yield this.releaseLogical(id, keepSession, retainLink);
1219
1824
  }
1220
1825
  catch (error) {
1221
1826
  (_a = this.Log) === null || _a === void 0 ? void 0 : _a.error('[Electron BLE] release failed:', error);
@@ -1252,13 +1857,18 @@ class ElectronBleTransport {
1252
1857
  }
1253
1858
  });
1254
1859
  }
1255
- releaseLogical(id, keepSession) {
1860
+ releaseLogical(id, keepSession, retainLink = false) {
1256
1861
  var _a, _b, _c, _d;
1257
1862
  return __awaiter(this, void 0, void 0, function* () {
1258
1863
  try {
1259
1864
  if (!this.connectedDevices.has(id))
1260
1865
  return;
1261
- this.cleanupDeviceState(id);
1866
+ if (retainLink) {
1867
+ this.retainedDevices.add(id);
1868
+ }
1869
+ else {
1870
+ this.cleanupDeviceState(id);
1871
+ }
1262
1872
  const release = (_b = (_a = window.desktopApi) === null || _a === void 0 ? void 0 : _a.nobleBle) === null || _b === void 0 ? void 0 : _b.release;
1263
1873
  if (!release) {
1264
1874
  if (!this.warnedMissingRelease) {
@@ -1277,10 +1887,12 @@ class ElectronBleTransport {
1277
1887
  });
1278
1888
  }
1279
1889
  createProtocolMismatchError(expected) {
1280
- return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.RuntimeError, `Device protocol mismatch: expected ${expected}, but device did not respond to expected protocol`);
1890
+ return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
1891
+ expectedProtocol: expected,
1892
+ });
1281
1893
  }
1282
1894
  createProtocolDetectionError() {
1283
- return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleTimeoutError, 'Unable to detect BLE protocol: device did not respond to Protocol V1 GetFeatures or Protocol V2 Ping');
1895
+ return hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.TransportProtocolProbeFailed);
1284
1896
  }
1285
1897
  clearProbeProtocol(uuid, protocol) {
1286
1898
  if (this.deviceProtocol.get(uuid) === protocol) {
@@ -1688,19 +2300,27 @@ class ElectronBleTransport {
1688
2300
  });
1689
2301
  }
1690
2302
  callProtocolV2(uuid, name, data, options) {
1691
- var _a;
2303
+ var _a, _b, _c;
1692
2304
  return __awaiter(this, void 0, void 0, function* () {
1693
2305
  if (!this._messages || !this._messagesV2) {
1694
2306
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.TransportNotConfigured);
1695
2307
  }
1696
2308
  const callOptions = options;
2309
+ this.protocolV2CallCounts.set(uuid, ((_a = this.protocolV2CallCounts.get(uuid)) !== null && _a !== void 0 ? _a : 0) + 1);
1697
2310
  try {
1698
2311
  return yield this.protocolV2Links.call(uuid, () => this.createProtocolV2Adapter(uuid), name, data, callOptions);
1699
2312
  }
1700
2313
  catch (e) {
1701
- (_a = this.Log) === null || _a === void 0 ? void 0 : _a.error('[Electron BLE] Protocol V2 call error:', e);
2314
+ (_b = this.Log) === null || _b === void 0 ? void 0 : _b.error('[Electron BLE] Protocol V2 call error:', e);
1702
2315
  throw e;
1703
2316
  }
2317
+ finally {
2318
+ const remaining = ((_c = this.protocolV2CallCounts.get(uuid)) !== null && _c !== void 0 ? _c : 1) - 1;
2319
+ if (remaining > 0)
2320
+ this.protocolV2CallCounts.set(uuid, remaining);
2321
+ else
2322
+ this.protocolV2CallCounts.delete(uuid);
2323
+ }
1704
2324
  });
1705
2325
  }
1706
2326
  createProtocolV2Adapter(uuid) {