@onekeyfe/hd-transport-web-device 1.2.8-alpha.100 → 1.2.8-alpha.102
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__/electron-ble-transport.test.ts +11 -7
- package/__tests__/webusb-identity-probe.test.ts +887 -0
- package/__tests__/webusb-protocol-cache.test.ts +197 -1
- package/__tests__/webusb-protocol-v2-timeout.test.ts +18 -5
- package/dist/electron-ble-transport.d.ts.map +1 -1
- package/dist/index.d.ts +37 -3
- package/dist/index.js +639 -58
- package/dist/webusb.d.ts +38 -3
- package/dist/webusb.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/electron-ble-transport.ts +4 -8
- package/src/webusb.ts +749 -52
package/src/webusb.ts
CHANGED
|
@@ -44,6 +44,9 @@ const REPORT_ID = PROTOCOL_V1_REPORT_ID;
|
|
|
44
44
|
const HEADER_LENGTH = PROTOCOL_V1_MESSAGE_HEADER_SIZE;
|
|
45
45
|
const PACKET_IO_MAX_RETRIES = 3;
|
|
46
46
|
const PACKET_IO_RETRY_DELAY = 300;
|
|
47
|
+
const USB_DEVICE_IN_USE = 'USB device is already in use';
|
|
48
|
+
const WEBUSB_LIFECYCLE_LOG_PREFIX = '[WebUsbTransport][lifecycle]';
|
|
49
|
+
const WEBUSB_ERROR_MESSAGE_LIMIT = 240;
|
|
47
50
|
// Legacy devices can take longer to answer Initialize after a WebUSB reset.
|
|
48
51
|
// Keep this aligned with Node USB so a slow Protocol V1 response is not
|
|
49
52
|
// misreported as a protocol mismatch during acquire.
|
|
@@ -73,6 +76,23 @@ interface TransferCancelToken {
|
|
|
73
76
|
|
|
74
77
|
type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
|
|
75
78
|
|
|
79
|
+
type WebUsbHandleOperation =
|
|
80
|
+
| 'open'
|
|
81
|
+
| 'reset'
|
|
82
|
+
| 'selectConfiguration'
|
|
83
|
+
| 'claimInterface'
|
|
84
|
+
| 'clearHaltIn'
|
|
85
|
+
| 'clearHaltOut'
|
|
86
|
+
| 'releaseInterface'
|
|
87
|
+
| 'close';
|
|
88
|
+
|
|
89
|
+
type WebUsbLifecyclePhase = 'start' | 'success' | 'error' | 'skipped';
|
|
90
|
+
|
|
91
|
+
interface WebUsbConnectOptions {
|
|
92
|
+
expectedProtocol?: ProtocolType;
|
|
93
|
+
forceProtocolDetection?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
76
96
|
/**
|
|
77
97
|
* The navigator.usb disconnect listener is module-scoped and attached at most
|
|
78
98
|
* once: listeners on navigator.usb are global and never garbage collected, so a
|
|
@@ -82,6 +102,13 @@ type WebUsbOperation = 'open' | 'claimInterface' | 'transferIn' | 'transferOut';
|
|
|
82
102
|
*/
|
|
83
103
|
let activeWebUsbTransport: WebUsbTransport | undefined;
|
|
84
104
|
let usbDisconnectListenerAttached = false;
|
|
105
|
+
let nextWebUsbTransportInstanceId = 0;
|
|
106
|
+
// Only isolated discovery owns these handles. Normal calls must never close or
|
|
107
|
+
// open them, including while native open() is still pending.
|
|
108
|
+
const identityProbeHandles = new WeakSet<USBDevice>();
|
|
109
|
+
const openingHandles = new WeakSet<USBDevice>();
|
|
110
|
+
// A normal acquire keeps ownership even while recovery temporarily closes the handle.
|
|
111
|
+
const normalHandleOwners = new WeakMap<USBDevice, WebUsbTransport>();
|
|
85
112
|
|
|
86
113
|
function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
|
|
87
114
|
activeWebUsbTransport = instance;
|
|
@@ -100,6 +127,14 @@ function registerActiveWebUsbTransport(instance: WebUsbTransport, usb: USB) {
|
|
|
100
127
|
}
|
|
101
128
|
|
|
102
129
|
export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string> {
|
|
130
|
+
private readonly transportInstanceId = ++nextWebUsbTransportInstanceId;
|
|
131
|
+
|
|
132
|
+
private handleOperationSequence = 0;
|
|
133
|
+
|
|
134
|
+
private deviceLogSequence = 0;
|
|
135
|
+
|
|
136
|
+
private deviceLogIds = new WeakMap<USBDevice, number>();
|
|
137
|
+
|
|
103
138
|
messages: ReturnType<typeof transport.parseConfigure> | undefined;
|
|
104
139
|
|
|
105
140
|
/** Protobuf schema for Protocol V2 transports. */
|
|
@@ -124,6 +159,16 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
124
159
|
/** Paths currently acquired (between a successful acquire() and its release()). */
|
|
125
160
|
private acquiredPaths: Set<string> = new Set();
|
|
126
161
|
|
|
162
|
+
private identityProbeDevices = new Map<string, USBDevice>();
|
|
163
|
+
|
|
164
|
+
private normalDevices = new Map<USBDevice, string>();
|
|
165
|
+
|
|
166
|
+
private pendingHandleOperations = new Set<Promise<unknown>>();
|
|
167
|
+
|
|
168
|
+
private stopPromise?: Promise<void>;
|
|
169
|
+
|
|
170
|
+
private identityProbeCleanupPaths = new Set<string>();
|
|
171
|
+
|
|
127
172
|
/**
|
|
128
173
|
* The exact USBDevice object each cached protocol was probed against. The
|
|
129
174
|
* browser returns the same object identity for a device as long as it stays
|
|
@@ -160,7 +205,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
160
205
|
|
|
161
206
|
interfaceId = INTERFACE_ID;
|
|
162
207
|
|
|
163
|
-
constructor() {
|
|
208
|
+
constructor(private readonly identityProbe = false) {
|
|
164
209
|
super({
|
|
165
210
|
router: PROTOCOL_V2_CHANNEL_USB,
|
|
166
211
|
maxFrameBytes: PROTOCOL_V2_FRAME_MAX_BYTES,
|
|
@@ -183,7 +228,250 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
183
228
|
);
|
|
184
229
|
}
|
|
185
230
|
this.usb = usb;
|
|
186
|
-
registerActiveWebUsbTransport(this, usb);
|
|
231
|
+
if (!this.identityProbe) registerActiveWebUsbTransport(this, usb);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
stop(): Promise<void> {
|
|
235
|
+
const startedAt = Date.now();
|
|
236
|
+
this.stopped = true;
|
|
237
|
+
this.acquiredPaths.clear();
|
|
238
|
+
if (activeWebUsbTransport === this) activeWebUsbTransport = undefined;
|
|
239
|
+
if (!this.stopPromise) {
|
|
240
|
+
this.logLifecycle('stop', { phase: 'start' });
|
|
241
|
+
this.stopPromise = this.stopResources().then(
|
|
242
|
+
() => {
|
|
243
|
+
this.logLifecycle('stop', {
|
|
244
|
+
phase: 'success',
|
|
245
|
+
durationMs: Date.now() - startedAt,
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
error => {
|
|
249
|
+
this.logLifecycle('stop', {
|
|
250
|
+
phase: 'error',
|
|
251
|
+
durationMs: Date.now() - startedAt,
|
|
252
|
+
...this.getDiagnosticError(error),
|
|
253
|
+
});
|
|
254
|
+
// A later discovery can retry closure without dropping the handle guard.
|
|
255
|
+
this.stopPromise = undefined;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return this.stopPromise;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async stopResources() {
|
|
264
|
+
let cleanupError: unknown;
|
|
265
|
+
try {
|
|
266
|
+
await this.disposeProtocolV2UsbLinks('WebUSB transport stopped');
|
|
267
|
+
} catch (error) {
|
|
268
|
+
cleanupError = error;
|
|
269
|
+
}
|
|
270
|
+
// Wait for late open/reset/claim/close completions before releasing ownership.
|
|
271
|
+
// Reads are aborted by closing their handle and reject late results below.
|
|
272
|
+
await Promise.allSettled([...this.pendingHandleOperations]);
|
|
273
|
+
const handles = this.identityProbe
|
|
274
|
+
? [...this.identityProbeDevices].map(([path, device]) => [device, path] as const)
|
|
275
|
+
: [...this.normalDevices];
|
|
276
|
+
for (const [device, path] of handles) {
|
|
277
|
+
if (!this.identityProbe && normalHandleOwners.get(device) !== this) {
|
|
278
|
+
this.normalDevices.delete(device);
|
|
279
|
+
} else {
|
|
280
|
+
try {
|
|
281
|
+
if (device.opened) {
|
|
282
|
+
const iface = this.deviceEndpoints.get(path)?.interfaceNumber ?? this.interfaceId;
|
|
283
|
+
if (this.getInterfaceClaimed(device, iface) === false) {
|
|
284
|
+
this.logSkippedHandleOperation(
|
|
285
|
+
'releaseInterface',
|
|
286
|
+
device,
|
|
287
|
+
'interface-not-claimed',
|
|
288
|
+
iface
|
|
289
|
+
);
|
|
290
|
+
} else {
|
|
291
|
+
try {
|
|
292
|
+
await this.runLoggedHandleOperation({
|
|
293
|
+
operation: 'releaseInterface',
|
|
294
|
+
device,
|
|
295
|
+
path,
|
|
296
|
+
interfaceNumber: iface,
|
|
297
|
+
allowStopped: true,
|
|
298
|
+
task: () => device.releaseInterface(iface),
|
|
299
|
+
});
|
|
300
|
+
} catch (error) {
|
|
301
|
+
this.Log?.debug('[WebUsbTransport] stop releaseInterface failed:', error);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
await this.runLoggedHandleOperation({
|
|
305
|
+
operation: 'close',
|
|
306
|
+
device,
|
|
307
|
+
path,
|
|
308
|
+
allowStopped: true,
|
|
309
|
+
task: () => device.close(),
|
|
310
|
+
});
|
|
311
|
+
if (device.opened) throw new Error('WebUSB handle remains open after stop');
|
|
312
|
+
}
|
|
313
|
+
if (this.identityProbe) {
|
|
314
|
+
identityProbeHandles.delete(device);
|
|
315
|
+
this.identityProbeDevices.delete(path);
|
|
316
|
+
this.identityProbeCleanupPaths.delete(path);
|
|
317
|
+
} else {
|
|
318
|
+
normalHandleOwners.delete(device);
|
|
319
|
+
this.normalDevices.delete(device);
|
|
320
|
+
}
|
|
321
|
+
} catch (error) {
|
|
322
|
+
cleanupError ??= error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
this.deviceList = [];
|
|
327
|
+
this.deviceProtocol.clear();
|
|
328
|
+
this.confirmedDeviceProtocols.clear();
|
|
329
|
+
this.deviceProtocolHints.clear();
|
|
330
|
+
this.staleProtocolPaths.clear();
|
|
331
|
+
this.probedDeviceObjects.clear();
|
|
332
|
+
if (!this.normalDevices.size && !this.identityProbeDevices.size) {
|
|
333
|
+
this.deviceEndpoints.clear();
|
|
334
|
+
}
|
|
335
|
+
if (cleanupError) {
|
|
336
|
+
throw cleanupError instanceof Error
|
|
337
|
+
? cleanupError
|
|
338
|
+
: new Error(this.getErrorMessage(cleanupError));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private assertRunning() {
|
|
343
|
+
if (this.stopped) {
|
|
344
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'WebUSB transport is stopped');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private async runHandleOperation<T>(operation: () => Promise<T>): Promise<T> {
|
|
349
|
+
this.assertRunning();
|
|
350
|
+
const pending = Promise.resolve(operation());
|
|
351
|
+
this.pendingHandleOperations.add(pending);
|
|
352
|
+
try {
|
|
353
|
+
const result = await pending;
|
|
354
|
+
this.assertRunning();
|
|
355
|
+
return result;
|
|
356
|
+
} finally {
|
|
357
|
+
this.pendingHandleOperations.delete(pending);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private getInterfaceClaimed(device: USBDevice, interfaceNumber: number) {
|
|
362
|
+
return device.configuration?.interfaces?.find(
|
|
363
|
+
usbInterface => usbInterface.interfaceNumber === interfaceNumber
|
|
364
|
+
)?.claimed;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private getDeviceLogId(device: USBDevice) {
|
|
368
|
+
const existing = this.deviceLogIds.get(device);
|
|
369
|
+
if (existing) return existing;
|
|
370
|
+
const next = ++this.deviceLogSequence;
|
|
371
|
+
this.deviceLogIds.set(device, next);
|
|
372
|
+
return next;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private getHandleLogState(device: USBDevice, interfaceNumber?: number) {
|
|
376
|
+
const interfaceClaimed =
|
|
377
|
+
interfaceNumber === undefined ? undefined : this.getInterfaceClaimed(device, interfaceNumber);
|
|
378
|
+
return {
|
|
379
|
+
deviceRefId: this.getDeviceLogId(device),
|
|
380
|
+
opened: device.opened,
|
|
381
|
+
configurationValue: device.configuration?.configurationValue ?? null,
|
|
382
|
+
interfaceNumber: interfaceNumber ?? null,
|
|
383
|
+
interfaceClaimed: interfaceClaimed ?? null,
|
|
384
|
+
identityProbe: this.identityProbe,
|
|
385
|
+
stopped: this.stopped,
|
|
386
|
+
acquiredPathCount: this.acquiredPaths.size,
|
|
387
|
+
normalHandleCount: this.normalDevices.size,
|
|
388
|
+
identityProbeHandleCount: this.identityProbeDevices.size,
|
|
389
|
+
pendingHandleOperationCount: this.pendingHandleOperations.size,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private getDiagnosticError(error: unknown, path?: string) {
|
|
394
|
+
const errorMessage = this.getErrorMessage(error);
|
|
395
|
+
const redactedMessage = path ? errorMessage.split(path).join('<device>') : errorMessage;
|
|
396
|
+
return {
|
|
397
|
+
errorName: this.getErrorName(error) || 'Error',
|
|
398
|
+
errorMessage: redactedMessage.slice(0, WEBUSB_ERROR_MESSAGE_LIMIT),
|
|
399
|
+
errorCode: error instanceof HardwareError ? error.errorCode : undefined,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private logLifecycle(event: string, payload: Record<string, unknown> = {}) {
|
|
404
|
+
this.Log?.debug(WEBUSB_LIFECYCLE_LOG_PREFIX, {
|
|
405
|
+
event,
|
|
406
|
+
transportInstanceId: this.transportInstanceId,
|
|
407
|
+
...payload,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private logSkippedHandleOperation(
|
|
412
|
+
operation: WebUsbHandleOperation,
|
|
413
|
+
device: USBDevice,
|
|
414
|
+
reason: string,
|
|
415
|
+
interfaceNumber?: number
|
|
416
|
+
) {
|
|
417
|
+
const operationId = ++this.handleOperationSequence;
|
|
418
|
+
this.logLifecycle('handle-operation', {
|
|
419
|
+
operationId,
|
|
420
|
+
operation,
|
|
421
|
+
phase: 'skipped' satisfies WebUsbLifecyclePhase,
|
|
422
|
+
reason,
|
|
423
|
+
...this.getHandleLogState(device, interfaceNumber),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private async runLoggedHandleOperation<T>({
|
|
428
|
+
operation,
|
|
429
|
+
device,
|
|
430
|
+
task,
|
|
431
|
+
path,
|
|
432
|
+
interfaceNumber,
|
|
433
|
+
endpointNumber,
|
|
434
|
+
allowStopped = false,
|
|
435
|
+
}: {
|
|
436
|
+
operation: WebUsbHandleOperation;
|
|
437
|
+
device: USBDevice;
|
|
438
|
+
task: () => Promise<T>;
|
|
439
|
+
path?: string;
|
|
440
|
+
interfaceNumber?: number;
|
|
441
|
+
endpointNumber?: number;
|
|
442
|
+
allowStopped?: boolean;
|
|
443
|
+
}): Promise<T> {
|
|
444
|
+
const operationId = ++this.handleOperationSequence;
|
|
445
|
+
const startedAt = Date.now();
|
|
446
|
+
const details = {
|
|
447
|
+
operationId,
|
|
448
|
+
operation,
|
|
449
|
+
...(endpointNumber === undefined ? {} : { endpointNumber }),
|
|
450
|
+
};
|
|
451
|
+
this.logLifecycle('handle-operation', {
|
|
452
|
+
...details,
|
|
453
|
+
phase: 'start' satisfies WebUsbLifecyclePhase,
|
|
454
|
+
...this.getHandleLogState(device, interfaceNumber),
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
const result = allowStopped ? await task() : await this.runHandleOperation(task);
|
|
458
|
+
this.logLifecycle('handle-operation', {
|
|
459
|
+
...details,
|
|
460
|
+
phase: 'success' satisfies WebUsbLifecyclePhase,
|
|
461
|
+
durationMs: Date.now() - startedAt,
|
|
462
|
+
...this.getHandleLogState(device, interfaceNumber),
|
|
463
|
+
});
|
|
464
|
+
return result;
|
|
465
|
+
} catch (error) {
|
|
466
|
+
this.logLifecycle('handle-operation', {
|
|
467
|
+
...details,
|
|
468
|
+
phase: 'error' satisfies WebUsbLifecyclePhase,
|
|
469
|
+
durationMs: Date.now() - startedAt,
|
|
470
|
+
...this.getHandleLogState(device, interfaceNumber),
|
|
471
|
+
...this.getDiagnosticError(error, path),
|
|
472
|
+
});
|
|
473
|
+
throw error;
|
|
474
|
+
}
|
|
187
475
|
}
|
|
188
476
|
|
|
189
477
|
/**
|
|
@@ -249,10 +537,22 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
249
537
|
*/
|
|
250
538
|
async promptDeviceAccess() {
|
|
251
539
|
if (!this.usb) return null;
|
|
540
|
+
const startedAt = Date.now();
|
|
541
|
+
this.logLifecycle('prompt-device-access', { phase: 'start' });
|
|
252
542
|
try {
|
|
253
543
|
const device = await this.usb.requestDevice({ filters: ONEKEY_WEBUSB_FILTER });
|
|
544
|
+
this.logLifecycle('prompt-device-access', {
|
|
545
|
+
phase: 'success',
|
|
546
|
+
durationMs: Date.now() - startedAt,
|
|
547
|
+
selected: Boolean(device),
|
|
548
|
+
});
|
|
254
549
|
return device;
|
|
255
550
|
} catch (e) {
|
|
551
|
+
this.logLifecycle('prompt-device-access', {
|
|
552
|
+
phase: 'error',
|
|
553
|
+
durationMs: Date.now() - startedAt,
|
|
554
|
+
...this.getDiagnosticError(e),
|
|
555
|
+
});
|
|
256
556
|
this.Log.debug(
|
|
257
557
|
'requestDevice error: ',
|
|
258
558
|
e instanceof Error ? `${e.name}: ${e.message}` : String(e)
|
|
@@ -267,17 +567,73 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
267
567
|
* It does NOT prompt the user to select a device
|
|
268
568
|
*/
|
|
269
569
|
async enumerate() {
|
|
270
|
-
|
|
271
|
-
|
|
570
|
+
const startedAt = Date.now();
|
|
571
|
+
this.logLifecycle('enumerate', {
|
|
572
|
+
phase: 'start',
|
|
573
|
+
pendingProbeCleanupCount: this.identityProbeCleanupPaths.size,
|
|
574
|
+
});
|
|
575
|
+
try {
|
|
576
|
+
// Retry only our failed cleanup before Core skips already-open USB handles.
|
|
577
|
+
for (const path of this.identityProbeCleanupPaths) {
|
|
578
|
+
try {
|
|
579
|
+
await this.release(path);
|
|
580
|
+
} catch {
|
|
581
|
+
// Keep ownership while native close still fails; retry on the next probe.
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
await this.getConnectedDevices();
|
|
585
|
+
this.logLifecycle('enumerate', {
|
|
586
|
+
phase: 'success',
|
|
587
|
+
durationMs: Date.now() - startedAt,
|
|
588
|
+
deviceCount: this.deviceList.length,
|
|
589
|
+
});
|
|
590
|
+
return this.deviceList;
|
|
591
|
+
} catch (error) {
|
|
592
|
+
this.logLifecycle('enumerate', {
|
|
593
|
+
phase: 'error',
|
|
594
|
+
durationMs: Date.now() - startedAt,
|
|
595
|
+
...this.getDiagnosticError(error),
|
|
596
|
+
});
|
|
597
|
+
throw error;
|
|
598
|
+
}
|
|
272
599
|
}
|
|
273
600
|
|
|
274
601
|
/**
|
|
275
602
|
* Get list of connected devices
|
|
276
603
|
*/
|
|
277
604
|
async getConnectedDevices() {
|
|
605
|
+
this.assertRunning();
|
|
278
606
|
if (!this.usb) return [];
|
|
279
607
|
|
|
280
|
-
const
|
|
608
|
+
const startedAt = Date.now();
|
|
609
|
+
this.logLifecycle('get-devices', { phase: 'start' });
|
|
610
|
+
let devices: USBDevice[];
|
|
611
|
+
try {
|
|
612
|
+
devices = await this.usb.getDevices();
|
|
613
|
+
this.logLifecycle('get-devices', {
|
|
614
|
+
phase: 'success',
|
|
615
|
+
durationMs: Date.now() - startedAt,
|
|
616
|
+
authorizedDeviceCount: devices.length,
|
|
617
|
+
});
|
|
618
|
+
} catch (error) {
|
|
619
|
+
this.logLifecycle('get-devices', {
|
|
620
|
+
phase: 'error',
|
|
621
|
+
durationMs: Date.now() - startedAt,
|
|
622
|
+
...this.getDiagnosticError(error),
|
|
623
|
+
});
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
this.assertRunning();
|
|
627
|
+
for (const owner of new Set(devices.map(device => normalHandleOwners.get(device)))) {
|
|
628
|
+
if (owner?.stopped) {
|
|
629
|
+
try {
|
|
630
|
+
await owner.stop();
|
|
631
|
+
} catch (error) {
|
|
632
|
+
this.Log?.debug('[WebUsbTransport] stopped owner cleanup failed:', error);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
this.assertRunning();
|
|
281
637
|
const onekeyDevices = devices.filter(dev => {
|
|
282
638
|
const isOneKey = ONEKEY_WEBUSB_FILTER.some(
|
|
283
639
|
(desc: { vendorId: number; productId: number }) =>
|
|
@@ -310,11 +666,30 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
310
666
|
* Acquire device control
|
|
311
667
|
*/
|
|
312
668
|
async acquire(input: AcquireInput) {
|
|
669
|
+
this.assertRunning();
|
|
313
670
|
if (!input.path) return;
|
|
671
|
+
const startedAt = Date.now();
|
|
672
|
+
this.logLifecycle('acquire', {
|
|
673
|
+
phase: 'start',
|
|
674
|
+
expectedProtocol: input.expectedProtocol ?? null,
|
|
675
|
+
protocolHint: input.protocolHint ?? null,
|
|
676
|
+
forceProtocolDetection: Boolean(input.forceProtocolDetection),
|
|
677
|
+
skipProtocolProbe: Boolean(input.skipProtocolProbe),
|
|
678
|
+
hasCachedProtocol: this.deviceProtocol.has(input.path),
|
|
679
|
+
});
|
|
314
680
|
try {
|
|
681
|
+
if (this.identityProbe) {
|
|
682
|
+
await this.getConnectedDevices();
|
|
683
|
+
this.claimIdentityProbeHandle(input.path, await this.findDevice(input.path));
|
|
684
|
+
} else {
|
|
685
|
+
this.claimNormalHandle(input.path, await this.findDevice(input.path));
|
|
686
|
+
}
|
|
315
687
|
await this.rotateProtocolV2UsbGeneration(input.path, 'WebUSB transport acquired');
|
|
316
688
|
await this.closeOpenDevice(input.path);
|
|
317
|
-
await this.connect(input.path ?? '', true
|
|
689
|
+
await this.connect(input.path ?? '', true, {
|
|
690
|
+
expectedProtocol: input.expectedProtocol,
|
|
691
|
+
forceProtocolDetection: input.forceProtocolDetection,
|
|
692
|
+
});
|
|
318
693
|
if (input.skipProtocolProbe) {
|
|
319
694
|
if (!input.expectedProtocol) {
|
|
320
695
|
throw ERRORS.TypedError(
|
|
@@ -383,18 +758,35 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
383
758
|
input.expectedProtocol,
|
|
384
759
|
protocolHint
|
|
385
760
|
);
|
|
761
|
+
this.assertRunning();
|
|
386
762
|
this.confirmedDeviceProtocols.set(input.path, detectedProtocol);
|
|
387
763
|
const probedDevice = this.deviceList.find(d => d.path === input.path)?.device;
|
|
388
764
|
if (probedDevice) {
|
|
389
765
|
this.probedDeviceObjects.set(input.path, probedDevice);
|
|
390
766
|
}
|
|
391
767
|
}
|
|
768
|
+
this.assertRunning();
|
|
392
769
|
this.acquiredPaths.add(input.path);
|
|
770
|
+
this.logLifecycle('acquire', {
|
|
771
|
+
phase: 'success',
|
|
772
|
+
durationMs: Date.now() - startedAt,
|
|
773
|
+
protocol: this.deviceProtocol.get(input.path) ?? null,
|
|
774
|
+
});
|
|
393
775
|
return await Promise.resolve(input.path);
|
|
394
776
|
} catch (e) {
|
|
777
|
+
this.logLifecycle('acquire', {
|
|
778
|
+
phase: 'error',
|
|
779
|
+
durationMs: Date.now() - startedAt,
|
|
780
|
+
...this.getDiagnosticError(e, input.path),
|
|
781
|
+
});
|
|
395
782
|
this.Log.debug('acquire error: ', e instanceof Error ? `${e.name}: ${e.message}` : String(e));
|
|
396
783
|
this.acquiredPaths.delete(input.path);
|
|
397
|
-
|
|
784
|
+
try {
|
|
785
|
+
await this.closeOpenDevice(input.path);
|
|
786
|
+
} finally {
|
|
787
|
+
this.releaseIdentityProbeHandle(input.path);
|
|
788
|
+
this.releaseNormalHandles(input.path);
|
|
789
|
+
}
|
|
398
790
|
throw e;
|
|
399
791
|
}
|
|
400
792
|
}
|
|
@@ -405,21 +797,20 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
405
797
|
* fall back to a Protocol V2 Ping probe.
|
|
406
798
|
*/
|
|
407
799
|
private createProtocolMismatchError(expected: ProtocolType) {
|
|
408
|
-
return ERRORS.TypedError(
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
);
|
|
800
|
+
return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
|
|
801
|
+
expectedProtocol: expected,
|
|
802
|
+
});
|
|
412
803
|
}
|
|
413
804
|
|
|
414
805
|
private createProtocolProbeTimeoutError(expected: ProtocolType, attempts: number) {
|
|
415
|
-
return ERRORS.TypedError(
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
);
|
|
806
|
+
return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed, undefined, {
|
|
807
|
+
expectedProtocol: expected,
|
|
808
|
+
attempts,
|
|
809
|
+
});
|
|
419
810
|
}
|
|
420
811
|
|
|
421
812
|
private createProtocolDetectionError() {
|
|
422
|
-
return ERRORS.TypedError(HardwareErrorCode.
|
|
813
|
+
return ERRORS.TypedError(HardwareErrorCode.TransportProtocolProbeFailed);
|
|
423
814
|
}
|
|
424
815
|
|
|
425
816
|
private async detectProtocol(
|
|
@@ -513,16 +904,55 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
513
904
|
/**
|
|
514
905
|
* Connect to device with retry mechanism
|
|
515
906
|
*/
|
|
516
|
-
async connect(path: string, first: boolean) {
|
|
907
|
+
async connect(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
|
|
517
908
|
const maxRetries = 5;
|
|
518
909
|
for (let i = 0; i < maxRetries; i++) {
|
|
910
|
+
this.assertRunning();
|
|
911
|
+
const attempt = i + 1;
|
|
912
|
+
const startedAt = Date.now();
|
|
913
|
+
const connectionStrategy = this.shouldUseLegacyV1Connection(path, options)
|
|
914
|
+
? 'protocol-v1-legacy-1.1.31'
|
|
915
|
+
: 'current';
|
|
916
|
+
this.logLifecycle('connect-attempt', {
|
|
917
|
+
phase: 'start',
|
|
918
|
+
attempt,
|
|
919
|
+
maxAttempts: maxRetries,
|
|
920
|
+
first,
|
|
921
|
+
connectionStrategy,
|
|
922
|
+
});
|
|
519
923
|
try {
|
|
520
|
-
|
|
924
|
+
const result = await this.connectToDevice(path, first, options);
|
|
925
|
+
this.logLifecycle('connect-attempt', {
|
|
926
|
+
phase: 'success',
|
|
927
|
+
attempt,
|
|
928
|
+
maxAttempts: maxRetries,
|
|
929
|
+
first,
|
|
930
|
+
connectionStrategy,
|
|
931
|
+
durationMs: Date.now() - startedAt,
|
|
932
|
+
});
|
|
933
|
+
return result;
|
|
521
934
|
} catch (e) {
|
|
522
|
-
|
|
935
|
+
this.assertRunning();
|
|
936
|
+
const isTerminalError =
|
|
937
|
+
this.isHandleOwnershipError(e) ||
|
|
938
|
+
(e instanceof HardwareError &&
|
|
939
|
+
(e.errorCode === HardwareErrorCode.BridgeNeedsPermission ||
|
|
940
|
+
e.errorCode === HardwareErrorCode.WebUsbDeviceAccessError));
|
|
941
|
+
const willRetry = !isTerminalError && attempt < maxRetries;
|
|
942
|
+
this.logLifecycle('connect-attempt', {
|
|
943
|
+
phase: 'error',
|
|
944
|
+
attempt,
|
|
945
|
+
maxAttempts: maxRetries,
|
|
946
|
+
first,
|
|
947
|
+
connectionStrategy,
|
|
948
|
+
durationMs: Date.now() - startedAt,
|
|
949
|
+
willRetry,
|
|
950
|
+
...this.getDiagnosticError(e, path),
|
|
951
|
+
});
|
|
952
|
+
if (isTerminalError) {
|
|
523
953
|
throw e;
|
|
524
954
|
}
|
|
525
|
-
if (
|
|
955
|
+
if (!willRetry) {
|
|
526
956
|
throw e;
|
|
527
957
|
}
|
|
528
958
|
await wait(i * 200);
|
|
@@ -530,6 +960,11 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
530
960
|
}
|
|
531
961
|
}
|
|
532
962
|
|
|
963
|
+
private shouldUseLegacyV1Connection(path: string, options: WebUsbConnectOptions) {
|
|
964
|
+
if (options.expectedProtocol) return options.expectedProtocol === 'V1';
|
|
965
|
+
return !options.forceProtocolDetection && this.deviceProtocol.get(path) === 'V1';
|
|
966
|
+
}
|
|
967
|
+
|
|
533
968
|
/**
|
|
534
969
|
* Discover vendor-class (0xFF) interface and its IN/OUT endpoint numbers from USB descriptors.
|
|
535
970
|
* Falls back to legacy hardcoded values if no vendor interface is found.
|
|
@@ -567,31 +1002,102 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
567
1002
|
* Connect to specific device.
|
|
568
1003
|
* Discovers interface/endpoint numbers from USB descriptors on first connection.
|
|
569
1004
|
*/
|
|
570
|
-
async connectToDevice(path: string, first: boolean) {
|
|
1005
|
+
async connectToDevice(path: string, first: boolean, options: WebUsbConnectOptions = {}) {
|
|
1006
|
+
this.assertRunning();
|
|
571
1007
|
let device: USBDevice = await this.findDevice(path);
|
|
1008
|
+
if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
|
|
1009
|
+
await this.closeOpenDevice(path);
|
|
1010
|
+
this.releaseIdentityProbeHandle(path);
|
|
1011
|
+
this.claimIdentityProbeHandle(path, device);
|
|
1012
|
+
}
|
|
1013
|
+
if (!this.identityProbe) this.claimNormalHandle(path, device);
|
|
1014
|
+
this.assertHandleAvailable(device);
|
|
572
1015
|
if (!device.opened) {
|
|
1016
|
+
openingHandles.add(device);
|
|
573
1017
|
try {
|
|
574
|
-
await
|
|
1018
|
+
await this.runLoggedHandleOperation({
|
|
1019
|
+
operation: 'open',
|
|
1020
|
+
device,
|
|
1021
|
+
path,
|
|
1022
|
+
task: () => device.open(),
|
|
1023
|
+
});
|
|
575
1024
|
} catch (error) {
|
|
576
|
-
return this.throwWebUsbIoError(path, 'open', error);
|
|
1025
|
+
return await this.throwWebUsbIoError(path, 'open', error);
|
|
1026
|
+
} finally {
|
|
1027
|
+
openingHandles.delete(device);
|
|
577
1028
|
}
|
|
578
1029
|
}
|
|
1030
|
+
|
|
1031
|
+
if (this.shouldUseLegacyV1Connection(path, options)) {
|
|
1032
|
+
// Match the last V1-only WebUSB lifecycle from SDK 1.1.31. Legacy devices
|
|
1033
|
+
// configure before reset and keep the same USBDevice object afterwards.
|
|
1034
|
+
if (first) {
|
|
1035
|
+
await this.runLoggedHandleOperation({
|
|
1036
|
+
operation: 'selectConfiguration',
|
|
1037
|
+
device,
|
|
1038
|
+
path,
|
|
1039
|
+
task: () => device.selectConfiguration(this.configurationId),
|
|
1040
|
+
});
|
|
1041
|
+
try {
|
|
1042
|
+
await this.runLoggedHandleOperation({
|
|
1043
|
+
operation: 'reset',
|
|
1044
|
+
device,
|
|
1045
|
+
path,
|
|
1046
|
+
task: () => device.reset(),
|
|
1047
|
+
});
|
|
1048
|
+
} catch {
|
|
1049
|
+
this.assertRunning();
|
|
1050
|
+
// Reset is best-effort in 1.1.31; runLoggedHandleOperation already recorded the error.
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const legacyEndpoints = {
|
|
1055
|
+
interfaceNumber: this.interfaceId,
|
|
1056
|
+
endpointIn: this.endpointId,
|
|
1057
|
+
endpointOut: this.endpointId,
|
|
1058
|
+
};
|
|
1059
|
+
this.deviceEndpoints.set(path, legacyEndpoints);
|
|
1060
|
+
await this.claimDeviceInterface(path, device, legacyEndpoints);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
579
1064
|
// A V1 packet retry continues an in-flight exchange. Preserve its endpoint
|
|
580
1065
|
// state as in the legacy transport; probing and V2 recovery still reset.
|
|
581
1066
|
if (first || this.deviceProtocol.get(path) !== 'V1') {
|
|
582
1067
|
try {
|
|
583
|
-
await
|
|
584
|
-
|
|
585
|
-
|
|
1068
|
+
await this.runLoggedHandleOperation({
|
|
1069
|
+
operation: 'reset',
|
|
1070
|
+
device,
|
|
1071
|
+
path,
|
|
1072
|
+
task: () => device.reset(),
|
|
1073
|
+
});
|
|
1074
|
+
} catch {
|
|
1075
|
+
this.assertRunning();
|
|
1076
|
+
// Reset remains best-effort; runLoggedHandleOperation already recorded the error.
|
|
586
1077
|
}
|
|
587
1078
|
}
|
|
588
1079
|
await this.getConnectedDevices();
|
|
589
1080
|
device = await this.findDevice(path);
|
|
1081
|
+
if (this.identityProbe && this.identityProbeDevices.get(path) !== device) {
|
|
1082
|
+
await this.closeOpenDevice(path);
|
|
1083
|
+
this.releaseIdentityProbeHandle(path);
|
|
1084
|
+
this.claimIdentityProbeHandle(path, device);
|
|
1085
|
+
}
|
|
1086
|
+
if (!this.identityProbe) this.claimNormalHandle(path, device);
|
|
1087
|
+
this.assertHandleAvailable(device);
|
|
590
1088
|
if (!device.opened) {
|
|
1089
|
+
openingHandles.add(device);
|
|
591
1090
|
try {
|
|
592
|
-
await
|
|
1091
|
+
await this.runLoggedHandleOperation({
|
|
1092
|
+
operation: 'open',
|
|
1093
|
+
device,
|
|
1094
|
+
path,
|
|
1095
|
+
task: () => device.open(),
|
|
1096
|
+
});
|
|
593
1097
|
} catch (error) {
|
|
594
|
-
return this.throwWebUsbIoError(path, 'open', error);
|
|
1098
|
+
return await this.throwWebUsbIoError(path, 'open', error);
|
|
1099
|
+
} finally {
|
|
1100
|
+
openingHandles.delete(device);
|
|
595
1101
|
}
|
|
596
1102
|
}
|
|
597
1103
|
|
|
@@ -600,47 +1106,182 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
600
1106
|
!device.configuration ||
|
|
601
1107
|
device.configuration.configurationValue !== this.configurationId
|
|
602
1108
|
) {
|
|
603
|
-
await
|
|
1109
|
+
await this.runLoggedHandleOperation({
|
|
1110
|
+
operation: 'selectConfiguration',
|
|
1111
|
+
device,
|
|
1112
|
+
path,
|
|
1113
|
+
task: () => device.selectConfiguration(this.configurationId),
|
|
1114
|
+
});
|
|
604
1115
|
}
|
|
605
1116
|
|
|
606
1117
|
// Discover endpoints from USB descriptors; descriptors are not used for protocol selection.
|
|
607
1118
|
const endpoints = this.discoverEndpoints(device);
|
|
608
1119
|
this.deviceEndpoints.set(path, endpoints);
|
|
1120
|
+
await this.claimDeviceInterface(path, device, endpoints);
|
|
1121
|
+
await this.clearEndpointHalt(path, device, 'in', endpoints.endpointIn);
|
|
1122
|
+
await this.clearEndpointHalt(path, device, 'out', endpoints.endpointOut);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
private async claimDeviceInterface(path: string, device: USBDevice, endpoints: DeviceEndpoints) {
|
|
609
1126
|
try {
|
|
610
|
-
await
|
|
1127
|
+
await this.runLoggedHandleOperation({
|
|
1128
|
+
operation: 'claimInterface',
|
|
1129
|
+
device,
|
|
1130
|
+
path,
|
|
1131
|
+
interfaceNumber: endpoints.interfaceNumber,
|
|
1132
|
+
task: () => device.claimInterface(endpoints.interfaceNumber),
|
|
1133
|
+
});
|
|
611
1134
|
} catch (error) {
|
|
612
1135
|
return this.throwWebUsbIoError(path, 'claimInterface', error);
|
|
613
1136
|
}
|
|
614
|
-
|
|
615
|
-
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
private assertHandleAvailable(device: USBDevice) {
|
|
1140
|
+
this.assertRunning();
|
|
1141
|
+
const normalOwner = normalHandleOwners.get(device);
|
|
1142
|
+
if (
|
|
1143
|
+
(normalOwner && normalOwner !== this) ||
|
|
1144
|
+
(identityProbeHandles.has(device) &&
|
|
1145
|
+
![...this.identityProbeDevices.values()].includes(device))
|
|
1146
|
+
) {
|
|
1147
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private claimNormalHandle(path: string, device: USBDevice) {
|
|
1152
|
+
this.assertHandleAvailable(device);
|
|
1153
|
+
normalHandleOwners.set(device, this);
|
|
1154
|
+
this.normalDevices.set(device, path);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
private releaseNormalHandles(path: string) {
|
|
1158
|
+
if (this.stopped) return;
|
|
1159
|
+
for (const [device, ownedPath] of this.normalDevices) {
|
|
1160
|
+
if (ownedPath === path && !device.opened) {
|
|
1161
|
+
// Do not expose a native handle to another transport while close failed.
|
|
1162
|
+
// A later release/stop can retry cleanup through normalDevices.
|
|
1163
|
+
normalHandleOwners.delete(device);
|
|
1164
|
+
this.normalDevices.delete(device);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
private isHandleOwnershipError(error: unknown): error is HardwareError {
|
|
1170
|
+
return (
|
|
1171
|
+
error instanceof HardwareError &&
|
|
1172
|
+
error.errorCode === HardwareErrorCode.RuntimeError &&
|
|
1173
|
+
this.getErrorMessage(error).includes(USB_DEVICE_IN_USE)
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
private claimIdentityProbeHandle(path: string, device: USBDevice) {
|
|
1178
|
+
this.assertRunning();
|
|
1179
|
+
if (
|
|
1180
|
+
device.opened ||
|
|
1181
|
+
openingHandles.has(device) ||
|
|
1182
|
+
identityProbeHandles.has(device) ||
|
|
1183
|
+
normalHandleOwners.has(device)
|
|
1184
|
+
) {
|
|
1185
|
+
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, USB_DEVICE_IN_USE);
|
|
1186
|
+
}
|
|
1187
|
+
identityProbeHandles.add(device);
|
|
1188
|
+
this.identityProbeDevices.set(path, device);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private releaseIdentityProbeHandle(path: string) {
|
|
1192
|
+
if (this.stopped) return;
|
|
1193
|
+
const device = this.identityProbeDevices.get(path);
|
|
1194
|
+
// A failed native close must not make an open probe handle available to other callers.
|
|
1195
|
+
if (device?.opened) {
|
|
1196
|
+
this.identityProbeCleanupPaths.add(path);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
if (device) identityProbeHandles.delete(device);
|
|
1200
|
+
this.identityProbeDevices.delete(path);
|
|
1201
|
+
this.identityProbeCleanupPaths.delete(path);
|
|
616
1202
|
}
|
|
617
1203
|
|
|
618
1204
|
private async closeOpenDevice(path: string) {
|
|
619
|
-
|
|
620
|
-
|
|
1205
|
+
if (this.stopped) return;
|
|
1206
|
+
const devices = this.identityProbe
|
|
1207
|
+
? [this.identityProbeDevices.get(path)].filter(
|
|
1208
|
+
(device): device is USBDevice => device !== undefined
|
|
1209
|
+
)
|
|
1210
|
+
: [...this.normalDevices]
|
|
1211
|
+
.filter(([, ownedPath]) => ownedPath === path)
|
|
1212
|
+
.map(([device]) => device);
|
|
1213
|
+
let firstCloseError: unknown;
|
|
1214
|
+
for (const device of devices) {
|
|
1215
|
+
try {
|
|
1216
|
+
await this.closeDeviceHandle(path, device);
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
firstCloseError ??= error;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
if (firstCloseError) {
|
|
1222
|
+
throw firstCloseError instanceof Error
|
|
1223
|
+
? firstCloseError
|
|
1224
|
+
: new Error(this.getErrorMessage(firstCloseError));
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
private async closeDeviceHandle(path: string, current: USBDevice) {
|
|
1229
|
+
if (!current.opened) return;
|
|
1230
|
+
this.assertHandleAvailable(current);
|
|
621
1231
|
|
|
622
1232
|
const endpoints = this.deviceEndpoints.get(path);
|
|
623
1233
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1234
|
+
if (this.getInterfaceClaimed(current, ifaceNum) === false) {
|
|
1235
|
+
this.logSkippedHandleOperation(
|
|
1236
|
+
'releaseInterface',
|
|
1237
|
+
current,
|
|
1238
|
+
'interface-not-claimed',
|
|
1239
|
+
ifaceNum
|
|
1240
|
+
);
|
|
1241
|
+
} else {
|
|
1242
|
+
try {
|
|
1243
|
+
await this.runLoggedHandleOperation({
|
|
1244
|
+
operation: 'releaseInterface',
|
|
1245
|
+
device: current,
|
|
1246
|
+
path,
|
|
1247
|
+
interfaceNumber: ifaceNum,
|
|
1248
|
+
task: () => current.releaseInterface(ifaceNum),
|
|
1249
|
+
});
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
this.Log?.debug('[WebUsbTransport] releaseInterface before reconnect failed:', error);
|
|
1252
|
+
}
|
|
628
1253
|
}
|
|
1254
|
+
this.assertHandleAvailable(current);
|
|
629
1255
|
try {
|
|
630
|
-
await
|
|
1256
|
+
await this.runLoggedHandleOperation({
|
|
1257
|
+
operation: 'close',
|
|
1258
|
+
device: current,
|
|
1259
|
+
path,
|
|
1260
|
+
task: () => current.close(),
|
|
1261
|
+
});
|
|
631
1262
|
} catch (error) {
|
|
632
1263
|
this.Log?.debug('[WebUsbTransport] close before reconnect failed:', error);
|
|
1264
|
+
throw error;
|
|
633
1265
|
}
|
|
634
1266
|
}
|
|
635
1267
|
|
|
636
1268
|
private async clearEndpointHalt(
|
|
1269
|
+
path: string,
|
|
637
1270
|
device: USBDevice,
|
|
638
1271
|
direction: USBDirection,
|
|
639
1272
|
endpointNumber: number
|
|
640
1273
|
) {
|
|
1274
|
+
this.assertHandleAvailable(device);
|
|
641
1275
|
try {
|
|
642
|
-
await
|
|
1276
|
+
await this.runLoggedHandleOperation({
|
|
1277
|
+
operation: direction === 'in' ? 'clearHaltIn' : 'clearHaltOut',
|
|
1278
|
+
device,
|
|
1279
|
+
path,
|
|
1280
|
+
endpointNumber,
|
|
1281
|
+
task: () => device.clearHalt(direction, endpointNumber),
|
|
1282
|
+
});
|
|
643
1283
|
} catch (error) {
|
|
1284
|
+
this.assertRunning();
|
|
644
1285
|
this.Log?.debug(
|
|
645
1286
|
`[WebUsbTransport] clearHalt ${direction} endpoint ${endpointNumber} failed, continuing:`,
|
|
646
1287
|
error
|
|
@@ -656,6 +1297,7 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
656
1297
|
* protocol entry produced the same fail-fast).
|
|
657
1298
|
*/
|
|
658
1299
|
private assertAcquired(path: string) {
|
|
1300
|
+
this.assertRunning();
|
|
659
1301
|
if (!this.acquiredPaths.has(path)) {
|
|
660
1302
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, `Device is not acquired for ${path}`);
|
|
661
1303
|
}
|
|
@@ -713,7 +1355,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
713
1355
|
operation: WebUsbOperation,
|
|
714
1356
|
error: unknown
|
|
715
1357
|
): Promise<never> {
|
|
716
|
-
|
|
1358
|
+
this.assertRunning();
|
|
1359
|
+
if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) throw error;
|
|
717
1360
|
if (error instanceof ProtocolV2LinkError && error.code !== 'io') throw error;
|
|
718
1361
|
|
|
719
1362
|
const nativeError = error instanceof ProtocolV2LinkError ? error.cause ?? error : error;
|
|
@@ -753,7 +1396,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
753
1396
|
}
|
|
754
1397
|
|
|
755
1398
|
private isRetryablePacketIoError(error: unknown) {
|
|
756
|
-
if (this.
|
|
1399
|
+
if (this.stopped) return false;
|
|
1400
|
+
if (this.isHandleOwnershipError(error) || this.isWebUsbIoHardwareError(error)) return false;
|
|
757
1401
|
const message = this.getErrorMessage(error).toLowerCase();
|
|
758
1402
|
if (this.isWebUsbPermissionDeniedMessage(message)) return false;
|
|
759
1403
|
return (
|
|
@@ -784,20 +1428,45 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
784
1428
|
// USB disconnect event itself was missed.
|
|
785
1429
|
this.staleProtocolPaths.add(path);
|
|
786
1430
|
await wait(attempt * PACKET_IO_RETRY_DELAY);
|
|
1431
|
+
this.assertRunning();
|
|
787
1432
|
|
|
788
1433
|
try {
|
|
789
1434
|
const currentDevice = await this.findDevice(path);
|
|
1435
|
+
this.assertHandleAvailable(currentDevice);
|
|
790
1436
|
if (currentDevice.opened) {
|
|
791
1437
|
const endpoints = this.deviceEndpoints.get(path);
|
|
792
1438
|
const ifaceNum = endpoints?.interfaceNumber ?? this.interfaceId;
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1439
|
+
if (this.getInterfaceClaimed(currentDevice, ifaceNum) === false) {
|
|
1440
|
+
this.logSkippedHandleOperation(
|
|
1441
|
+
'releaseInterface',
|
|
1442
|
+
currentDevice,
|
|
1443
|
+
'interface-not-claimed',
|
|
1444
|
+
ifaceNum
|
|
1445
|
+
);
|
|
1446
|
+
} else {
|
|
1447
|
+
try {
|
|
1448
|
+
await this.runLoggedHandleOperation({
|
|
1449
|
+
operation: 'releaseInterface',
|
|
1450
|
+
device: currentDevice,
|
|
1451
|
+
path,
|
|
1452
|
+
interfaceNumber: ifaceNum,
|
|
1453
|
+
task: () => currentDevice.releaseInterface(ifaceNum),
|
|
1454
|
+
});
|
|
1455
|
+
} catch (releaseError) {
|
|
1456
|
+
this.Log.debug('[WebUsbTransport] releaseInterface before retry error:', releaseError);
|
|
1457
|
+
}
|
|
797
1458
|
}
|
|
798
|
-
|
|
1459
|
+
this.assertHandleAvailable(currentDevice);
|
|
1460
|
+
await this.runLoggedHandleOperation({
|
|
1461
|
+
operation: 'close',
|
|
1462
|
+
device: currentDevice,
|
|
1463
|
+
path,
|
|
1464
|
+
task: () => currentDevice.close(),
|
|
1465
|
+
});
|
|
799
1466
|
}
|
|
800
1467
|
} catch (closeError) {
|
|
1468
|
+
this.assertRunning();
|
|
1469
|
+
if (this.isHandleOwnershipError(closeError)) throw closeError;
|
|
801
1470
|
this.Log.debug('[WebUsbTransport] close device before retry error:', closeError);
|
|
802
1471
|
}
|
|
803
1472
|
|
|
@@ -839,6 +1508,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
839
1508
|
try {
|
|
840
1509
|
await this.reconnectForPacketIoRetry(path, 'out', attempt, error);
|
|
841
1510
|
} catch (reconnectError) {
|
|
1511
|
+
this.assertRunning();
|
|
1512
|
+
if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
|
|
842
1513
|
lastError = reconnectError;
|
|
843
1514
|
this.Log.debug(
|
|
844
1515
|
`[WebUsbTransport] transferout reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
|
|
@@ -861,7 +1532,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
861
1532
|
const transferBuffer = this.toArrayBuffer(
|
|
862
1533
|
packet.buffer.slice(packet.byteOffset, packet.byteOffset + packet.byteLength)
|
|
863
1534
|
);
|
|
1535
|
+
this.assertHandleAvailable(device);
|
|
864
1536
|
const result = await device.transferOut(endpointOut, transferBuffer);
|
|
1537
|
+
this.assertHandleAvailable(device);
|
|
865
1538
|
if (result.status !== 'ok') {
|
|
866
1539
|
return this.throwWebUsbIoError(
|
|
867
1540
|
path,
|
|
@@ -888,7 +1561,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
888
1561
|
}
|
|
889
1562
|
const endpoints = this.deviceEndpoints.get(path);
|
|
890
1563
|
const endpointIn = endpoints?.endpointIn ?? this.endpointId;
|
|
1564
|
+
this.assertHandleAvailable(device);
|
|
891
1565
|
const result = await device.transferIn(endpointIn, length);
|
|
1566
|
+
this.assertHandleAvailable(device);
|
|
892
1567
|
return this.getTransferInData(result);
|
|
893
1568
|
} catch (error) {
|
|
894
1569
|
lastError = error;
|
|
@@ -902,6 +1577,8 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
902
1577
|
try {
|
|
903
1578
|
await this.reconnectForPacketIoRetry(path, 'in', attempt, error);
|
|
904
1579
|
} catch (reconnectError) {
|
|
1580
|
+
this.assertRunning();
|
|
1581
|
+
if (this.isHandleOwnershipError(reconnectError)) throw reconnectError;
|
|
905
1582
|
lastError = reconnectError;
|
|
906
1583
|
this.Log.debug(
|
|
907
1584
|
`[WebUsbTransport] transferin reconnect failed on retry ${attempt}/${PACKET_IO_MAX_RETRIES}: ${this.getErrorMessage(
|
|
@@ -921,7 +1598,9 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
921
1598
|
}
|
|
922
1599
|
const endpoints = this.deviceEndpoints.get(path);
|
|
923
1600
|
const endpointIn = endpoints?.endpointIn ?? this.endpointId;
|
|
1601
|
+
this.assertHandleAvailable(device);
|
|
924
1602
|
const result = await device.transferIn(endpointIn, length);
|
|
1603
|
+
this.assertHandleAvailable(device);
|
|
925
1604
|
return this.getTransferInData(result);
|
|
926
1605
|
}
|
|
927
1606
|
|
|
@@ -984,15 +1663,15 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
984
1663
|
}
|
|
985
1664
|
|
|
986
1665
|
try {
|
|
987
|
-
await this.callProtocolV1(
|
|
1666
|
+
const response = await this.callProtocolV1(
|
|
988
1667
|
path,
|
|
989
|
-
'Initialize',
|
|
1668
|
+
this.identityProbe ? 'GetFeatures' : 'Initialize',
|
|
990
1669
|
{},
|
|
991
1670
|
{
|
|
992
1671
|
timeoutMs: PROTOCOL_V1_PROBE_TIMEOUT,
|
|
993
1672
|
}
|
|
994
1673
|
);
|
|
995
|
-
return
|
|
1674
|
+
return !this.identityProbe || response.type === 'Features';
|
|
996
1675
|
} catch (error) {
|
|
997
1676
|
if (this.isWebUsbIoHardwareError(error)) throw error;
|
|
998
1677
|
return false;
|
|
@@ -1148,9 +1827,27 @@ export default class WebUsbTransport extends ProtocolV2UsbTransportBase<string>
|
|
|
1148
1827
|
* Release device
|
|
1149
1828
|
*/
|
|
1150
1829
|
async release(path: string) {
|
|
1830
|
+
const startedAt = Date.now();
|
|
1831
|
+
this.logLifecycle('release', { phase: 'start' });
|
|
1151
1832
|
this.acquiredPaths.delete(path);
|
|
1152
|
-
|
|
1153
|
-
|
|
1833
|
+
try {
|
|
1834
|
+
await this.invalidateProtocolV2UsbLink(path, 'WebUSB transport released');
|
|
1835
|
+
await this.closeOpenDevice(path);
|
|
1836
|
+
this.logLifecycle('release', {
|
|
1837
|
+
phase: 'success',
|
|
1838
|
+
durationMs: Date.now() - startedAt,
|
|
1839
|
+
});
|
|
1840
|
+
} catch (error) {
|
|
1841
|
+
this.logLifecycle('release', {
|
|
1842
|
+
phase: 'error',
|
|
1843
|
+
durationMs: Date.now() - startedAt,
|
|
1844
|
+
...this.getDiagnosticError(error, path),
|
|
1845
|
+
});
|
|
1846
|
+
throw error;
|
|
1847
|
+
} finally {
|
|
1848
|
+
this.releaseIdentityProbeHandle(path);
|
|
1849
|
+
this.releaseNormalHandles(path);
|
|
1850
|
+
}
|
|
1154
1851
|
// Keep deviceProtocol/deviceProtocolHints across release: the probe result is a
|
|
1155
1852
|
// physical-device property, so the next acquire can skip the wire-level probe.
|
|
1156
1853
|
// V2 entries are still dropped by onProtocolV2UsbLinkInvalidated via the link
|