@onekeyfe/hd-transport-electron 1.2.2-alpha.100 → 1.2.2-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.
@@ -1,8 +1,8 @@
1
1
  import { EventEmitter } from 'events';
2
- import { EOneKeyBleMessageKeys } from '@onekeyfe/hd-shared';
2
+ import { EOneKeyBleMessageKeys, HardwareErrorCode } from '@onekeyfe/hd-shared';
3
3
 
4
4
  import {
5
- NOBLE_BLE_CONNECTION_TIMEOUT_MS,
5
+ NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
6
6
  NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
7
7
  } from '../noble-ble-timeouts';
8
8
 
@@ -26,9 +26,126 @@ describe('Electron Noble BLE device discovery', () => {
26
26
  jest.clearAllMocks();
27
27
  });
28
28
 
29
- test('allows enough time for a slow targeted scan and connection', () => {
29
+ test('bounds targeted scans and subscription callbacks independently', () => {
30
30
  expect(NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS).toBe(5_000);
31
- expect(NOBLE_BLE_CONNECTION_TIMEOUT_MS).toBe(10_000);
31
+ expect(NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS).toBe(10_000);
32
+ });
33
+
34
+ test('maps structured macOS stale pairing failures without parsing localized text', async () => {
35
+ const { createNobleBleConnectionError } = await import('../noble-ble-handler');
36
+
37
+ const staleBondError = createNobleBleConnectionError(
38
+ Object.assign(new Error('Peer removed pairing information on the device side'), {
39
+ nativeErrorCode: 14,
40
+ nativeErrorDomain: 'CBErrorDomain',
41
+ })
42
+ );
43
+ expect(staleBondError).toMatchObject({
44
+ errorCode: HardwareErrorCode.BleBondInvalid,
45
+ params: {
46
+ nativeErrorMessage: 'Peer removed pairing information on the device side',
47
+ },
48
+ });
49
+ expect(staleBondError.message).toContain('Peer removed pairing information on the device side');
50
+ expect(createNobleBleConnectionError(new Error('Encryption is insufficient'))).toMatchObject({
51
+ errorCode: HardwareErrorCode.BleConnectedError,
52
+ });
53
+ expect(
54
+ createNobleBleConnectionError(
55
+ Object.assign(new Error('localized native message'), {
56
+ nativeErrorCode: 14,
57
+ nativeErrorDomain: 'CBATTErrorDomain',
58
+ })
59
+ )
60
+ ).toMatchObject({
61
+ errorCode: HardwareErrorCode.BleConnectedError,
62
+ });
63
+ expect(
64
+ createNobleBleConnectionError(
65
+ Object.assign(new Error('Encryption is insufficient'), {
66
+ nativeErrorCode: 15,
67
+ nativeErrorDomain: 'CBATTErrorDomain',
68
+ })
69
+ )
70
+ ).toMatchObject({
71
+ errorCode: HardwareErrorCode.BleBondInvalid,
72
+ });
73
+ expect(createNobleBleConnectionError(new Error('connection failed'))).toMatchObject({
74
+ errorCode: HardwareErrorCode.BleConnectedError,
75
+ });
76
+ });
77
+
78
+ test('serializes HardwareError fields for the Noble IPC boundary', async () => {
79
+ const { createNobleBleIpcErrorResponse } = await import('../noble-ble-handler');
80
+
81
+ expect(
82
+ createNobleBleIpcErrorResponse({
83
+ name: 'HardwareError',
84
+ message: 'Bluetooth pairing information is no longer valid',
85
+ errorCode: HardwareErrorCode.BleBondInvalid,
86
+ params: { nativeErrorMessage: 'native message' },
87
+ })
88
+ ).toEqual({
89
+ type: 'NobleBleIpcError',
90
+ success: false,
91
+ error: {
92
+ name: 'HardwareError',
93
+ message: 'Bluetooth pairing information is no longer valid',
94
+ errorCode: HardwareErrorCode.BleBondInvalid,
95
+ params: { nativeErrorMessage: 'native message' },
96
+ },
97
+ });
98
+
99
+ expect(createNobleBleIpcErrorResponse(new Error('untyped failure'))).toEqual({
100
+ type: 'NobleBleIpcError',
101
+ success: false,
102
+ error: {
103
+ name: 'Error',
104
+ message: 'untyped failure',
105
+ errorCode: HardwareErrorCode.UnknownError,
106
+ },
107
+ });
108
+
109
+ const circularParams: { self?: unknown } = {};
110
+ circularParams.self = circularParams;
111
+ expect(
112
+ createNobleBleIpcErrorResponse({
113
+ message: 'failure with unsafe params',
114
+ errorCode: HardwareErrorCode.BleConnectedError,
115
+ params: circularParams,
116
+ })
117
+ ).toEqual({
118
+ type: 'NobleBleIpcError',
119
+ success: false,
120
+ error: {
121
+ name: 'Error',
122
+ message: 'failure with unsafe params',
123
+ errorCode: HardwareErrorCode.BleConnectedError,
124
+ },
125
+ });
126
+ });
127
+
128
+ test('rejects a structured Noble IPC failure at the preload boundary', async () => {
129
+ const { invokeNobleBleIpc } = await import('../types/desktop-api');
130
+
131
+ await expect(
132
+ invokeNobleBleIpc(
133
+ Promise.resolve({
134
+ type: 'NobleBleIpcError' as const,
135
+ success: false as const,
136
+ error: {
137
+ name: 'HardwareError',
138
+ message: 'Bluetooth pairing information is no longer valid',
139
+ errorCode: HardwareErrorCode.BleBondInvalid,
140
+ },
141
+ })
142
+ )
143
+ ).rejects.toMatchObject({
144
+ name: 'HardwareError',
145
+ errorCode: HardwareErrorCode.BleBondInvalid,
146
+ });
147
+
148
+ await expect(invokeNobleBleIpc(Promise.resolve('connected'))).resolves.toBe('connected');
32
149
  });
33
150
 
34
151
  test('keeps safe pacing by default and allows an explicit high-throughput bypass', async () => {
@@ -176,13 +293,19 @@ describe('Electron Noble BLE device discovery', () => {
176
293
  expect(peripheral.connect).not.toHaveBeenCalled();
177
294
 
178
295
  stopScanningCallback?.();
179
- await expect(connectPromise).rejects.toThrow('expected test connection failure');
296
+ await expect(connectPromise).resolves.toEqual({
297
+ type: 'NobleBleIpcError',
298
+ success: false,
299
+ error: {
300
+ name: 'HardwareError',
301
+ message: 'expected test connection failure',
302
+ errorCode: HardwareErrorCode.BleConnectedError,
303
+ },
304
+ });
180
305
  expect(peripheral.connect).toHaveBeenCalledTimes(1);
181
306
  });
182
307
 
183
- test('disconnects a connection callback that arrives after timeout', async () => {
184
- jest.useFakeTimers({ doNotFake: ['performance'] });
185
-
308
+ test('disconnects and settles a pending Noble connect before a late callback arrives', async () => {
186
309
  const handlers = new Map<string, IpcHandler>();
187
310
  const ipcMain = {
188
311
  handle: jest.fn((channel: string, handler: IpcHandler) => {
@@ -197,6 +320,7 @@ describe('Electron Noble BLE device discovery', () => {
197
320
  startScanning: jest.Mock;
198
321
  stopScanning: jest.Mock;
199
322
  };
323
+ let stopScanningCallback: (() => void) | undefined;
200
324
  let connectCallback: ((error?: Error) => void) | undefined;
201
325
  let resolveConnectStarted = () => undefined;
202
326
  const connectStarted = new Promise<void>(resolve => {
@@ -204,13 +328,17 @@ describe('Electron Noble BLE device discovery', () => {
204
328
  });
205
329
  const peripheral = Object.assign(
206
330
  new EventEmitter(),
207
- createPeripheral('slow-device', 'Pro2 A1B2'),
331
+ createPeripheral('pending-device', 'Pro2 C3D4'),
208
332
  {
209
333
  connect: jest.fn((callback: (error?: Error) => void) => {
210
334
  connectCallback = callback;
211
335
  resolveConnectStarted();
212
336
  }),
213
- disconnect: jest.fn((callback: () => void) => callback()),
337
+ disconnect: jest.fn((callback: () => void) => {
338
+ peripheral.state = 'disconnected';
339
+ callback();
340
+ }),
341
+ discoverServices: jest.fn(),
214
342
  }
215
343
  );
216
344
  noble.state = 'poweredOn';
@@ -218,7 +346,9 @@ describe('Electron Noble BLE device discovery', () => {
218
346
  callback?.();
219
347
  noble.emit('discover', peripheral);
220
348
  });
221
- noble.stopScanning = jest.fn(callback => callback?.());
349
+ noble.stopScanning = jest.fn(callback => {
350
+ stopScanningCallback = callback;
351
+ });
222
352
 
223
353
  jest.doMock('@stoprocent/noble', () => noble);
224
354
  jest.doMock('electron', () => ({ ipcMain }));
@@ -235,20 +365,257 @@ describe('Electron Noble BLE device discovery', () => {
235
365
  } as unknown as WebContents);
236
366
 
237
367
  const connect = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT);
238
- if (!connect) {
239
- throw new Error('Electron Noble BLE connect handler was not registered');
368
+ const disconnect = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT);
369
+ if (!connect || !disconnect) {
370
+ throw new Error('Electron Noble BLE handlers were not registered');
240
371
  }
241
372
 
242
- const connectPromise = Promise.resolve(connect(undefined, 'slow-device'));
373
+ const connectPromise = Promise.resolve(connect(undefined, peripheral.id));
374
+ await Promise.resolve();
375
+ stopScanningCallback?.();
243
376
  await connectStarted;
244
- expect(peripheral.connect).toHaveBeenCalledTimes(1);
245
377
 
246
- jest.advanceTimersByTime(NOBLE_BLE_CONNECTION_TIMEOUT_MS);
247
- await expect(connectPromise).rejects.toThrow('Connection timeout');
378
+ await expect(Promise.resolve(disconnect(undefined, peripheral.id))).resolves.toBeUndefined();
379
+ await expect(connectPromise).resolves.toMatchObject({
380
+ type: 'NobleBleIpcError',
381
+ success: false,
382
+ error: {
383
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
384
+ },
385
+ });
386
+ expect(peripheral.disconnect).toHaveBeenCalledTimes(1);
248
387
 
388
+ peripheral.state = 'connected';
249
389
  connectCallback?.();
250
390
  await Promise.resolve();
251
- expect(peripheral.disconnect).toHaveBeenCalledTimes(1);
391
+
392
+ expect(peripheral.discoverServices).not.toHaveBeenCalled();
393
+ expect(peripheral.disconnect).toHaveBeenCalledTimes(2);
394
+ });
395
+
396
+ test('disconnects and settles a pending forced reconnect before a late callback arrives', async () => {
397
+ const handlers = new Map<string, IpcHandler>();
398
+ const ipcMain = {
399
+ handle: jest.fn((channel: string, handler: IpcHandler) => {
400
+ handlers.set(channel, handler);
401
+ }),
402
+ removeHandler: jest.fn((channel: string) => {
403
+ handlers.delete(channel);
404
+ }),
405
+ };
406
+ const noble = new EventEmitter() as EventEmitter & {
407
+ state: string;
408
+ startScanning: jest.Mock;
409
+ stopScanning: jest.Mock;
410
+ };
411
+ let stopScanningCallback: (() => void) | undefined;
412
+ const connectCallbacks: Array<(error?: Error) => void> = [];
413
+ let resolveInitialConnectStarted = () => undefined;
414
+ const initialConnectStarted = new Promise<void>(resolve => {
415
+ resolveInitialConnectStarted = resolve;
416
+ });
417
+ let resolveForcedReconnectStarted = () => undefined;
418
+ const forcedReconnectStarted = new Promise<void>(resolve => {
419
+ resolveForcedReconnectStarted = resolve;
420
+ });
421
+ const peripheral = Object.assign(
422
+ new EventEmitter(),
423
+ createPeripheral('forced-reconnect-device', 'Pro2 C3D4'),
424
+ {
425
+ connect: jest.fn((callback: (error?: Error) => void) => {
426
+ connectCallbacks.push(callback);
427
+ if (connectCallbacks.length === 1) {
428
+ resolveInitialConnectStarted();
429
+ } else if (connectCallbacks.length === 2) {
430
+ resolveForcedReconnectStarted();
431
+ }
432
+ }),
433
+ disconnect: jest.fn((callback: () => void) => {
434
+ peripheral.state = 'disconnected';
435
+ callback();
436
+ }),
437
+ discoverServices: jest.fn(),
438
+ }
439
+ );
440
+ noble.state = 'poweredOn';
441
+ noble.startScanning = jest.fn((_services, _duplicates, callback) => {
442
+ callback?.();
443
+ noble.emit('discover', peripheral);
444
+ });
445
+ noble.stopScanning = jest.fn(callback => {
446
+ stopScanningCallback = callback;
447
+ });
448
+
449
+ jest.doMock('@stoprocent/noble', () => noble);
450
+ jest.doMock('electron', () => ({ ipcMain }));
451
+ jest.doMock('electron-log', () => ({
452
+ info: jest.fn(),
453
+ debug: jest.fn(),
454
+ error: jest.fn(),
455
+ }));
456
+
457
+ const { setupNobleBleHandlers } = await import('../noble-ble-handler');
458
+ setupNobleBleHandlers({
459
+ on: jest.fn(),
460
+ send: jest.fn(),
461
+ } as unknown as WebContents);
462
+
463
+ const connect = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT);
464
+ const disconnect = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT);
465
+ if (!connect || !disconnect) {
466
+ throw new Error('Electron Noble BLE handlers were not registered');
467
+ }
468
+
469
+ const connectPromise = Promise.resolve(connect(undefined, peripheral.id));
470
+ await Promise.resolve();
471
+ stopScanningCallback?.();
472
+ await initialConnectStarted;
473
+
474
+ peripheral.state = 'connected';
475
+ connectCallbacks[0]?.();
476
+ await forcedReconnectStarted;
477
+
478
+ await expect(Promise.resolve(disconnect(undefined, peripheral.id))).resolves.toBeUndefined();
479
+ await expect(connectPromise).resolves.toMatchObject({
480
+ type: 'NobleBleIpcError',
481
+ success: false,
482
+ error: {
483
+ errorCode: HardwareErrorCode.BleDeviceDisconnected,
484
+ },
485
+ });
486
+ expect(peripheral.disconnect).toHaveBeenCalledTimes(2);
487
+
488
+ peripheral.state = 'connected';
489
+ connectCallbacks[1]?.();
490
+ await Promise.resolve();
491
+
492
+ expect(peripheral.discoverServices).not.toHaveBeenCalled();
493
+ expect(peripheral.disconnect).toHaveBeenCalledTimes(3);
494
+ });
495
+
496
+ test('maps a structured native subscription failure before returning it over IPC', async () => {
497
+ const handlers = new Map<string, IpcHandler>();
498
+ const ipcMain = {
499
+ handle: jest.fn((channel: string, handler: IpcHandler) => {
500
+ handlers.set(channel, handler);
501
+ }),
502
+ removeHandler: jest.fn((channel: string) => {
503
+ handlers.delete(channel);
504
+ }),
505
+ };
506
+ const nativeError = Object.assign(new Error('Encryption is insufficient'), {
507
+ nativeErrorCode: 15,
508
+ nativeErrorDomain: 'CBATTErrorDomain',
509
+ });
510
+ const notifyCharacteristic = Object.assign(new EventEmitter(), {
511
+ uuid: '0003',
512
+ unsubscribe: jest.fn((callback: (error?: Error) => void) => callback()),
513
+ subscribe: jest.fn((callback: (error?: Error) => void) => callback(nativeError)),
514
+ });
515
+ const writeCharacteristic = Object.assign(new EventEmitter(), {
516
+ uuid: '0002',
517
+ });
518
+ const service = {
519
+ uuid: '0001',
520
+ discoverCharacteristics: jest.fn(
521
+ (
522
+ _characteristicUuids: string[],
523
+ callback: (error: Error | null, value: unknown[]) => void
524
+ ) => callback(null, [writeCharacteristic, notifyCharacteristic])
525
+ ),
526
+ };
527
+ const peripheral = Object.assign(
528
+ new EventEmitter(),
529
+ createPeripheral('subscription-device', 'Pro2 E5F6'),
530
+ {
531
+ connect: jest.fn((callback: (error?: Error) => void) => {
532
+ peripheral.state = 'connected';
533
+ callback();
534
+ }),
535
+ disconnect: jest.fn((callback: () => void) => {
536
+ peripheral.state = 'disconnected';
537
+ callback();
538
+ }),
539
+ discoverServices: jest.fn(
540
+ (_serviceUuids: string[], callback: (error: Error | null, value: unknown[]) => void) =>
541
+ callback(null, [service])
542
+ ),
543
+ }
544
+ );
545
+ const noble = Object.assign(new EventEmitter(), {
546
+ state: 'poweredOn',
547
+ startScanning: jest.fn((_services, _duplicates, callback) => {
548
+ callback?.();
549
+ noble.emit('discover', peripheral);
550
+ }),
551
+ stopScanning: jest.fn(callback => callback?.()),
552
+ });
553
+
554
+ jest.doMock('@stoprocent/noble', () => noble);
555
+ jest.doMock('electron', () => ({ ipcMain }));
556
+ jest.doMock('electron-log', () => ({
557
+ info: jest.fn(),
558
+ debug: jest.fn(),
559
+ error: jest.fn(),
560
+ }));
561
+
562
+ const { setupNobleBleHandlers } = await import('../noble-ble-handler');
563
+ setupNobleBleHandlers({
564
+ on: jest.fn(),
565
+ send: jest.fn(),
566
+ } as unknown as WebContents);
567
+
568
+ const connect = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT);
569
+ const subscribe = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE);
570
+ const disconnect = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT);
571
+ if (!connect || !subscribe || !disconnect) {
572
+ throw new Error('Electron Noble BLE handlers were not registered');
573
+ }
574
+
575
+ await expect(Promise.resolve(connect(undefined, peripheral.id))).resolves.toBeUndefined();
576
+ try {
577
+ await expect(Promise.resolve(subscribe(undefined, peripheral.id))).resolves.toMatchObject({
578
+ type: 'NobleBleIpcError',
579
+ success: false,
580
+ error: {
581
+ errorCode: HardwareErrorCode.BleBondInvalid,
582
+ params: {
583
+ nativeErrorMessage: 'Notification subscription failed: Encryption is insufficient',
584
+ },
585
+ },
586
+ });
587
+ } finally {
588
+ await disconnect(undefined, peripheral.id);
589
+ }
590
+ });
591
+
592
+ test('settles a pending Noble connect with the native disconnect error', () => {
593
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
594
+ const Noble = require('@stoprocent/noble/lib/noble');
595
+ const bindings = Object.assign(new EventEmitter(), {
596
+ connect: jest.fn(),
597
+ });
598
+ const noble = new Noble(bindings);
599
+ noble._registerListeners();
600
+ const peripheral = noble._createPeripheral(
601
+ 'aabbccdd',
602
+ '',
603
+ 'unknown',
604
+ true,
605
+ { localName: 'Pro2 A1B2', serviceUuids: ['0001'] },
606
+ -50,
607
+ false
608
+ );
609
+ const connectCallback = jest.fn();
610
+
611
+ peripheral.connect(connectCallback);
612
+ const nativeError = new Error(
613
+ 'CBErrorDomain:14 Peer removed pairing information on the device side'
614
+ );
615
+ bindings.emit('disconnect', 'aabbccdd', nativeError);
616
+
617
+ expect(connectCallback).toHaveBeenCalledWith(nativeError);
618
+ expect(peripheral.state).toBe('disconnected');
252
619
  });
253
620
 
254
621
  test('enumerates a Pro2 communication advertisement after Find My changes its name', async () => {
@@ -381,3 +748,127 @@ describe('Electron Noble BLE device discovery', () => {
381
748
  ]);
382
749
  });
383
750
  });
751
+
752
+ describe('Noble BLE process shutdown', () => {
753
+ afterEach(() => {
754
+ jest.useRealTimers();
755
+ jest.resetModules();
756
+ jest.clearAllMocks();
757
+ });
758
+
759
+ const setup = async (state = 'poweredOn') => {
760
+ const handlers = new Map<string, IpcHandler>();
761
+ const native = Object.assign(new EventEmitter(), {
762
+ state,
763
+ startScanning: jest.fn((_uuids, _duplicates, callback) => callback?.()),
764
+ stopScanning: jest.fn(callback => callback?.()),
765
+ stop: jest.fn(),
766
+ });
767
+ jest.doMock('@stoprocent/noble', () => native);
768
+ jest.doMock('electron', () => ({
769
+ ipcMain: {
770
+ handle: (channel: string, listener: IpcHandler) => handlers.set(channel, listener),
771
+ removeHandler: (channel: string) => handlers.delete(channel),
772
+ },
773
+ }));
774
+ jest.doMock(
775
+ 'electron-log',
776
+ () => ({
777
+ info: jest.fn(),
778
+ debug: jest.fn(),
779
+ warn: jest.fn(),
780
+ error: jest.fn(),
781
+ }),
782
+ { virtual: true }
783
+ );
784
+ const sdk = await import('../noble-ble-handler');
785
+ const window = new EventEmitter();
786
+ sdk.setupNobleBleHandlers(window as unknown as WebContents);
787
+ return { sdk, native, window, handlers };
788
+ };
789
+
790
+ test('does not initialize native BLE when quitting before first use', async () => {
791
+ const { sdk, native, handlers } = await setup();
792
+ await sdk.disposeNobleBleSupport();
793
+ expect(native.stop).not.toHaveBeenCalled();
794
+ expect(handlers.size).toBe(0);
795
+ });
796
+
797
+ test('cancels an active scan, ignores its late callback and releases native once', async () => {
798
+ jest.useFakeTimers({ doNotFake: ['performance'] });
799
+ const { sdk, native, handlers } = await setup();
800
+ let completeScan: (() => void) | undefined;
801
+ native.startScanning.mockImplementation((_uuids, _duplicates, callback) => {
802
+ completeScan = callback;
803
+ });
804
+ const scan = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE)?.({});
805
+ await Promise.resolve();
806
+ await Promise.resolve();
807
+ await sdk.disposeNobleBleSupport();
808
+ await sdk.disposeNobleBleSupport();
809
+ await scan;
810
+ completeScan?.();
811
+ expect(native.stop).toHaveBeenCalledTimes(1);
812
+ expect(native.listenerCount('discover')).toBe(0);
813
+ expect(native.listenerCount('stateChange')).toBe(0);
814
+ expect(jest.getTimerCount()).toBe(0);
815
+ });
816
+
817
+ test('cancels power-on waits before releasing native', async () => {
818
+ jest.useFakeTimers({ doNotFake: ['performance'] });
819
+ const { sdk, native, handlers } = await setup('unknown');
820
+ const availability = handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
821
+ await sdk.disposeNobleBleSupport();
822
+ await availability;
823
+ expect(native.stop).toHaveBeenCalledTimes(1);
824
+ expect(native.listenerCount('stateChange')).toBe(0);
825
+ expect(jest.getTimerCount()).toBe(0);
826
+ });
827
+
828
+ test('disconnects an in-flight connection and rejects its late success', async () => {
829
+ const { sdk, native, handlers } = await setup();
830
+ await handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
831
+ let connectCallback: (() => void) | undefined;
832
+ const peripheral = Object.assign(new EventEmitter(), {
833
+ ...createPeripheral('pending-device', 'OneKey Pro'),
834
+ connect: jest.fn(callback => {
835
+ connectCallback = callback;
836
+ }),
837
+ disconnect: jest.fn(callback => callback?.()),
838
+ discoverServices: jest.fn(),
839
+ });
840
+ native.emit('discover', peripheral);
841
+ const connecting = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT)?.({}, peripheral.id);
842
+ await Promise.resolve();
843
+ await Promise.resolve();
844
+ expect(peripheral.connect).toHaveBeenCalledTimes(1);
845
+ await sdk.disposeNobleBleSupport();
846
+ expect(peripheral.disconnect).toHaveBeenCalled();
847
+ expect(native.stop).toHaveBeenCalledTimes(1);
848
+ connectCallback?.();
849
+ expect(await connecting).toMatchObject({ success: false });
850
+ expect(peripheral.discoverServices).not.toHaveBeenCalled();
851
+ });
852
+
853
+ test('allows a host to defer shared native release until both transports are idle', async () => {
854
+ const { sdk, native, handlers } = await setup();
855
+ await handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
856
+ const releaseNoble = jest.fn();
857
+ await sdk.disposeNobleBleSupport(releaseNoble);
858
+ expect(releaseNoble).toHaveBeenCalledWith(native);
859
+ expect(native.stop).not.toHaveBeenCalled();
860
+ });
861
+
862
+ test('window destruction preserves the native manager for a soft restart', async () => {
863
+ const { sdk, native, handlers, window } = await setup();
864
+ await handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
865
+ window.emit('destroyed');
866
+ await Promise.resolve();
867
+ await Promise.resolve();
868
+ sdk.setupNobleBleHandlers(new EventEmitter() as unknown as WebContents);
869
+ await handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
870
+ expect(native.stop).not.toHaveBeenCalled();
871
+ await sdk.disposeNobleBleSupport();
872
+ expect(native.stop).toHaveBeenCalledTimes(1);
873
+ });
874
+ });
@@ -1,5 +1,19 @@
1
1
  const BLE_ATT_HEADER_BYTES = 3;
2
2
 
3
+ export function resolveNobleAttMtu(
4
+ reportedMtu: number | null | undefined,
5
+ platform: NodeJS.Platform = process.platform
6
+ ): number | undefined {
7
+ if (typeof reportedMtu !== 'number' || !Number.isFinite(reportedMtu) || reportedMtu <= 0) {
8
+ return undefined;
9
+ }
10
+
11
+ const normalizedMtu = Math.floor(reportedMtu);
12
+ return platform === 'darwin' || platform === 'win32'
13
+ ? normalizedMtu + BLE_ATT_HEADER_BYTES
14
+ : normalizedMtu;
15
+ }
16
+
3
17
  export function resolveBlePacketCapacity(
4
18
  mtu: number | null | undefined,
5
19
  maximumPacketCapacity: number,
package/src/index.ts CHANGED
@@ -3,9 +3,22 @@ import type { WebContents } from 'electron';
3
3
  export * from './types';
4
4
 
5
5
  // Export Desktop API types for other packages to reuse
6
- export type { DesktopAPI, NobleBleAPI, NobleBleWriteOptions } from './types/desktop-api';
6
+ export type {
7
+ DesktopAPI,
8
+ NobleBleAPI,
9
+ NobleBleIpcErrorPayload,
10
+ NobleBleIpcErrorResponse,
11
+ NobleBleWriteOptions,
12
+ } from './types/desktop-api';
13
+ export { invokeNobleBleIpc, isNobleBleIpcErrorResponse } from './types/desktop-api';
7
14
 
8
15
  export async function initNobleBleSupport(webContents: WebContents) {
9
16
  const { setupNobleBleHandlers } = await import('./noble-ble-handler');
10
17
  setupNobleBleHandlers(webContents);
11
18
  }
19
+
20
+ /** Terminal process cleanup. A host sharing Noble can defer native stop until all users are idle. */
21
+ export async function disposeNobleBleSupport(releaseNoble?: (noble: { stop(): void }) => void) {
22
+ const handler = await import('./noble-ble-handler');
23
+ await handler.disposeNobleBleSupport(releaseNoble);
24
+ }