@onekeyfe/hd-transport-electron 1.2.0-alpha.7 → 1.2.0-alpha.70

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.
@@ -9,14 +9,24 @@ import {
9
9
  EOneKeyBleMessageKeys,
10
10
  ERRORS,
11
11
  HardwareErrorCode,
12
+ ONEKEY_NOTIFY_CHARACTERISTIC_UUID,
12
13
  ONEKEY_SERVICE_UUID,
13
- isOnekeyDevice,
14
+ ONEKEY_WRITE_CHARACTERISTIC_UUID,
15
+ createKnownBleUuidAliases,
16
+ hasOnekeyCommunicationService,
17
+ isOnekeyBluetoothDevice,
18
+ isPro2FindMyAdvertisementName,
19
+ matchesKnownBleUuid,
14
20
  wait,
15
21
  } from '@onekeyfe/hd-shared';
16
22
  import pRetry from 'p-retry';
17
23
 
18
24
  import { safeLog } from './types/noble-extended';
19
- import { softRefreshSubscription } from './ble-ops';
25
+ import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
26
+ import {
27
+ NOBLE_BLE_CONNECTION_TIMEOUT_MS,
28
+ NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
29
+ } from './noble-ble-timeouts';
20
30
 
21
31
  import type { IpcMainInvokeEvent, WebContents } from 'electron';
22
32
  import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
@@ -39,6 +49,7 @@ const bluetoothState: {
39
49
 
40
50
  // Global persistent state listener for app layer
41
51
  let persistentStateListener: ((state: string) => void) | null = null;
52
+ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null;
42
53
 
43
54
  // Device cache and connection state
44
55
  const discoveredDevices = new Map<string, Peripheral>();
@@ -50,6 +61,10 @@ const subscribedDevices = new Map<string, boolean>(); // Track subscription stat
50
61
 
51
62
  // 🔒 Subscription operation state tracking to prevent race conditions
52
63
  const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' | 'idle'>();
64
+ const deviceDisconnectListeners = new Map<
65
+ string,
66
+ { peripheral: Peripheral; listener: () => void }
67
+ >();
53
68
 
54
69
  // Windows-only response watchdog state moved to utils/windows-ble-recovery
55
70
 
@@ -59,19 +74,16 @@ const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' |
59
74
 
60
75
  // Service UUIDs to scan for - using constants from hd-shared
61
76
  const ONEKEY_SERVICE_UUIDS = [ONEKEY_SERVICE_UUID];
62
- const PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS = new Set(['fffd']);
63
-
64
- // Pre-normalized characteristic identifiers for fast comparison
65
- const NORMALIZED_WRITE_UUID = '0002';
66
- const NORMALIZED_NOTIFY_UUID = '0003';
77
+ const ONEKEY_SERVICE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_SERVICE_UUID);
78
+ const ONEKEY_WRITE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_WRITE_CHARACTERISTIC_UUID);
79
+ const ONEKEY_NOTIFY_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_NOTIFY_CHARACTERISTIC_UUID);
67
80
 
68
81
  // Timeout and interval constants
69
82
  const BLUETOOTH_INIT_TIMEOUT = 10000; // 10 seconds for Bluetooth initialization
70
- const DEVICE_SCAN_TIMEOUT = 8000; // 8 seconds for device scanning (Pro2 has longer advertising interval)
71
- const FAST_SCAN_TIMEOUT = 8000; // 8 seconds for targeted scanning (Pro2 has longer advertising interval)
83
+ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
72
84
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
73
- const CONNECTION_TIMEOUT = 8000; // 8 seconds for device connection (BLE reconnect after release can be slow)
74
85
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
86
+ const BLE_CLEANUP_TIMEOUT = 250;
75
87
 
76
88
  // Write-related constants
77
89
  const BLE_PACKET_SIZE = 192;
@@ -82,34 +94,24 @@ const ABORTABLE_WRITE_ERROR_PATTERNS = [
82
94
  /status:\s*3/i, // Windows pairing cancelled / GATT write failed
83
95
  ];
84
96
 
85
- function getBleUuidKey(uuid?: string | null) {
86
- const normalized = (uuid ?? '').replace(/-/g, '').toLowerCase();
87
- return normalized.length >= 8 ? normalized.substring(4, 8) : normalized;
88
- }
89
-
90
- const NORMALIZED_ONEKEY_SERVICE_UUIDS = new Set([
91
- ...ONEKEY_SERVICE_UUIDS.map(uuid => getBleUuidKey(uuid)),
92
- '0001',
93
- ]);
94
-
95
- function isGenericBleService(uuid?: string | null) {
96
- return ['1800', '1801', '180a', '180f'].includes(getBleUuidKey(uuid));
97
- }
97
+ function isOneKeyPeripheral(peripheral: Peripheral) {
98
+ const serviceUuids = peripheral.advertisement?.serviceUuids;
99
+ const localName = peripheral.advertisement?.localName;
98
100
 
99
- function hasOneKeyAdvertisementService(peripheral: Peripheral) {
100
- const serviceUuids = peripheral.advertisement?.serviceUuids ?? [];
101
- return serviceUuids.some(uuid => {
102
- const uuidKey = getBleUuidKey(uuid);
103
- return (
104
- NORMALIZED_ONEKEY_SERVICE_UUIDS.has(uuidKey) ||
105
- PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(uuidKey)
106
- );
107
- });
108
- }
101
+ // Noble localName is the current advertisement name, so reject the Pro2
102
+ // Find My endpoint before the communication-service fast path accepts it.
103
+ if (!localName?.trim() || isPro2FindMyAdvertisementName(localName)) {
104
+ return false;
105
+ }
109
106
 
110
- function isOneKeyPeripheral(peripheral: Peripheral) {
111
- const deviceName = peripheral.advertisement?.localName || null;
112
- return isOnekeyDevice(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
107
+ return (
108
+ hasOnekeyCommunicationService(serviceUuids) &&
109
+ isOnekeyBluetoothDevice({
110
+ id: peripheral.id,
111
+ localName,
112
+ serviceUuids,
113
+ })
114
+ );
113
115
  }
114
116
 
115
117
  /**
@@ -117,12 +119,6 @@ function isOneKeyPeripheral(peripheral: Peripheral) {
117
119
  * renderer-side transport. Packet reassembly is handled by ElectronBleTransport.
118
120
  */
119
121
  function emitRawNotification(deviceId: string, data: Buffer): void {
120
- logger?.info('[NobleBLE] Raw notification', {
121
- deviceId,
122
- dataLength: data.length,
123
- firstBytes: data.subarray(0, 8).toString('hex'),
124
- });
125
-
126
122
  const appCb = notificationCallbacks.get(deviceId);
127
123
  if (appCb) appCb(data.toString('hex'));
128
124
  }
@@ -296,9 +292,12 @@ async function initializeNoble(): Promise<void> {
296
292
  });
297
293
 
298
294
  // Set up device discovery
299
- noble.on('discover', (peripheral: Peripheral) => {
300
- handleDeviceDiscovered(peripheral);
301
- });
295
+ if (!persistentDiscoverListener) {
296
+ persistentDiscoverListener = (peripheral: Peripheral) => {
297
+ handleDeviceDiscovered(peripheral);
298
+ };
299
+ noble.on('discover', persistentDiscoverListener);
300
+ }
302
301
 
303
302
  logger?.info('[NobleBLE] Noble initialized successfully');
304
303
  } catch (error) {
@@ -360,6 +359,11 @@ function cleanupDevice(
360
359
 
361
360
  // 1. Clean up connection state
362
361
  if (cleanupConnection) {
362
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
363
+ if (disconnectEntry) {
364
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
365
+ deviceDisconnectListeners.delete(deviceId);
366
+ }
363
367
  connectedDevices.delete(deviceId);
364
368
  deviceCharacteristics.delete(deviceId);
365
369
  notificationCallbacks.delete(deviceId);
@@ -409,13 +413,16 @@ function setupDisconnectListener(
409
413
  deviceId: string,
410
414
  webContents: WebContents
411
415
  ): void {
412
- // Remove any existing disconnect listeners to avoid duplicates
413
- peripheral.removeAllListeners('disconnect');
416
+ const existing = deviceDisconnectListeners.get(deviceId);
417
+ if (existing) {
418
+ existing.peripheral.removeListener('disconnect', existing.listener);
419
+ }
414
420
 
415
- // Set up new disconnect listener
416
- peripheral.on('disconnect', () => {
421
+ const listener = () => {
417
422
  handleDeviceDisconnect(deviceId, webContents);
418
- });
423
+ };
424
+ deviceDisconnectListeners.set(deviceId, { peripheral, listener });
425
+ peripheral.on('disconnect', listener);
419
426
  }
420
427
 
421
428
  // ===== Write helpers (inline) =====
@@ -551,12 +558,6 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
551
558
  }
552
559
 
553
560
  const toBuffer = Buffer.from(hexData, 'hex');
554
- logger?.info('[NobleBLE] Writing data:', {
555
- deviceId,
556
- dataLength: toBuffer.length,
557
- firstBytes: toBuffer.subarray(0, 8).toString('hex'),
558
- });
559
-
560
561
  const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
561
562
 
562
563
  if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
@@ -618,9 +619,6 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
618
619
 
619
620
  // Handle discovered device (for general enumeration only)
620
621
  function handleDeviceDiscovered(peripheral: Peripheral): void {
621
- const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
622
- const serviceUuids = peripheral.advertisement?.serviceUuids || [];
623
-
624
622
  // Only process OneKey candidates for general discovery. Avoid logging every
625
623
  // ambient BLE peripheral; it makes Pro2 debugging hard to read.
626
624
  if (!isOneKeyPeripheral(peripheral)) {
@@ -630,10 +628,10 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
630
628
  const isNewDevice = !discoveredDevices.has(peripheral.id);
631
629
  discoveredDevices.set(peripheral.id, peripheral);
632
630
  if (isNewDevice) {
633
- logger?.info('[NobleBLE] Discovered OneKey BLE device:', {
634
- name: deviceName,
635
- id: peripheral.id,
636
- serviceUUIDs: serviceUuids,
631
+ logger?.debug('[NobleBLE] OneKey BLE device discovered', {
632
+ deviceId: peripheral.id,
633
+ name: peripheral.advertisement?.localName || 'Unknown Device',
634
+ serviceUUIDs: peripheral.advertisement?.serviceUuids || [],
637
635
  });
638
636
  }
639
637
  }
@@ -643,19 +641,24 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
643
641
  function ensureDiscoverListener(): void {
644
642
  if (!noble) return;
645
643
 
646
- // Check if discover listener exists by checking listener count
647
- const listenerCount = (noble as any).listenerCount('discover');
648
-
649
- if (listenerCount === 0) {
644
+ if (!persistentDiscoverListener) {
650
645
  logger?.info('[NobleBLE] Discover listener missing, re-adding it');
651
- noble.on('discover', (peripheral: Peripheral) => {
646
+ persistentDiscoverListener = (peripheral: Peripheral) => {
652
647
  handleDeviceDiscovered(peripheral);
653
- });
648
+ };
649
+ noble.on('discover', persistentDiscoverListener);
654
650
  } else {
655
- logger?.debug('[NobleBLE] Discover listener already exists, count:', listenerCount);
651
+ logger?.debug('[NobleBLE] Discover listener already registered');
656
652
  }
657
653
  }
658
654
 
655
+ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
656
+ await runBleCallbackOperation(callback => nobleInstance.stopScanning(() => callback()), {
657
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
658
+ timeoutBehavior: 'resolve',
659
+ });
660
+ }
661
+
659
662
  // Perform targeted scan for a specific device ID
660
663
  // Uses self-contained local listener pattern - no global state needed
661
664
  async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
@@ -669,28 +672,41 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
669
672
  logger?.info('[NobleBLE] Starting targeted scan for device:', targetDeviceId);
670
673
 
671
674
  return new Promise((resolve, reject) => {
675
+ let settled = false;
676
+
677
+ const finish = async (peripheral: Peripheral | null, error?: Error) => {
678
+ if (settled) return;
679
+ settled = true;
680
+ if (timeoutId) clearTimeout(timeoutId);
681
+ nobleInstance.removeListener('discover', onDiscover);
682
+ await waitForNobleScanStop(nobleInstance);
683
+
684
+ if (error) {
685
+ logger?.error('[NobleBLE] Failed to start targeted scan:', error);
686
+ reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
687
+ return;
688
+ }
689
+ if (peripheral) {
690
+ discoveredDevices.set(peripheral.id, peripheral);
691
+ }
692
+ resolve(peripheral);
693
+ };
694
+
672
695
  // Local discover listener - only matches target device
673
696
  const onDiscover = (peripheral: Peripheral) => {
674
- if (peripheral.id === targetDeviceId) {
697
+ if (peripheral.id === targetDeviceId && isOneKeyPeripheral(peripheral)) {
675
698
  logger?.info('[NobleBLE] Target device found during targeted scan:', {
676
699
  id: peripheral.id,
677
700
  name: peripheral.advertisement?.localName,
678
701
  });
679
- clearTimeout(timeoutId);
680
- nobleInstance.removeListener('discover', onDiscover);
681
- nobleInstance.stopScanning();
682
- discoveredDevices.set(peripheral.id, peripheral);
683
- resolve(peripheral);
702
+ finish(peripheral).catch(reject);
684
703
  }
685
704
  };
686
705
 
687
- // Timeout handler - must be after onDiscover so it can reference it
688
706
  const timeoutId = setTimeout(() => {
689
- nobleInstance.removeListener('discover', onDiscover);
690
- nobleInstance.stopScanning();
691
707
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
692
- resolve(null);
693
- }, FAST_SCAN_TIMEOUT);
708
+ finish(null).catch(reject);
709
+ }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
694
710
 
695
711
  // Add local listener for this scan
696
712
  nobleInstance.on('discover', onDiscover);
@@ -698,10 +714,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
698
714
  // Start scanning — no service UUID filter (Pro2 may use different service UUID)
699
715
  nobleInstance.startScanning([], false, (error?: Error) => {
700
716
  if (error) {
701
- clearTimeout(timeoutId);
702
- nobleInstance.removeListener('discover', onDiscover);
703
- logger?.error('[NobleBLE] Failed to start targeted scan:', error);
704
- reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
717
+ finish(null, error).catch(reject);
705
718
  return;
706
719
  }
707
720
  logger?.info('[NobleBLE] Targeted scan started for device:', targetDeviceId);
@@ -735,11 +748,13 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
735
748
  const devices: DeviceInfo[] = [];
736
749
  let intervalId: ReturnType<typeof setInterval> | undefined;
737
750
 
738
- // Cleanup function: clears both timeout and interval
739
- const cleanup = () => {
751
+ // Cleanup function: clears timers and waits until Noble confirms scanning
752
+ // has stopped. Resolving enumerate before this callback creates a race with
753
+ // an immediately-following connection attempt.
754
+ const cleanup = async () => {
740
755
  clearTimeout(timeoutId);
741
756
  if (intervalId) clearInterval(intervalId);
742
- nobleInstance.stopScanning();
757
+ await waitForNobleScanStop(nobleInstance);
743
758
  };
744
759
 
745
760
  // Collect discovered devices into the devices array
@@ -759,10 +774,10 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
759
774
  };
760
775
 
761
776
  // Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
762
- const timeoutId = setTimeout(() => {
777
+ const timeoutId = setTimeout(async () => {
763
778
  // Final collection before resolving — catches devices discovered near the deadline
764
779
  checkDevices();
765
- cleanup();
780
+ await cleanup();
766
781
  logger?.info('[NobleBLE] Scan completed, found devices:', devices.length);
767
782
  resolve(devices);
768
783
  }, DEVICE_SCAN_TIMEOUT);
@@ -770,9 +785,9 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
770
785
  // Start scanning without a service UUID filter so Pro2 advertisements with
771
786
  // short vendor UUIDs can be found, but only OneKey candidates are logged/returned.
772
787
  logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
773
- nobleInstance.startScanning([], false, (error?: Error) => {
788
+ nobleInstance.startScanning([], false, async (error?: Error) => {
774
789
  if (error) {
775
- cleanup();
790
+ await cleanup();
776
791
  logger?.error('[NobleBLE] Failed to start scanning:', error);
777
792
  reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
778
793
  return;
@@ -789,30 +804,21 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
789
804
  // Stop scanning
790
805
  async function stopScanning(): Promise<void> {
791
806
  if (!noble) return;
792
-
793
- return new Promise<void>(resolve => {
794
- if (!noble) {
795
- resolve();
796
- return;
797
- }
798
-
799
- noble.stopScanning(() => {
800
- logger?.info('[NobleBLE] Scanning stopped');
801
- resolve();
802
- });
803
- });
807
+ const nobleInstance = noble;
808
+ await waitForNobleScanStop(nobleInstance);
809
+ logger?.info('[NobleBLE] Scanning stopped');
804
810
  }
805
811
 
806
812
  // Clean up all Noble listeners (for app exit)
807
813
  function cleanupNobleListeners(): void {
808
814
  if (!noble) return;
809
815
 
810
- // Remove all listeners to prevent memory leaks
811
- // Noble uses EventEmitter, need to use removeAllListeners
812
816
  try {
813
- (noble as any).removeAllListeners('discover');
814
- (noble as any).removeAllListeners('stateChange');
815
- logger?.info('[NobleBLE] All Noble listeners cleaned up');
817
+ if (persistentDiscoverListener) {
818
+ noble.removeListener('discover', persistentDiscoverListener);
819
+ persistentDiscoverListener = null;
820
+ }
821
+ logger?.info('[NobleBLE] Owned Noble listeners cleaned up');
816
822
  } catch (error) {
817
823
  logger?.error('[NobleBLE] Failed to clean up some listeners:', error);
818
824
  }
@@ -910,31 +916,25 @@ async function discoverServicesAndCharacteristics(
910
916
  });
911
917
  });
912
918
 
913
- // Log all discovered services
914
- logger?.info(
915
- '[NobleBLE] All services:',
916
- services?.map(s => s.uuid)
917
- );
918
-
919
919
  if (!services || services.length === 0) {
920
920
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No services found');
921
921
  }
922
922
 
923
+ logger?.debug('[NobleBLE] services discovered', {
924
+ deviceId: peripheral.id,
925
+ serviceUUIDs: services.map(service => service.uuid),
926
+ });
927
+
923
928
  // Find OneKey service — Noble may expose 128-bit UUIDs as short UUID keys.
924
- let service = services.find(s => NORMALIZED_ONEKEY_SERVICE_UUIDS.has(getBleUuidKey(s.uuid)));
925
- if (!service) {
926
- logger?.info('[NobleBLE] Known OneKey service UUID not found, trying first vendor service');
927
- service =
928
- services.find(s => PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(getBleUuidKey(s.uuid))) ||
929
- services.find(s => !isGenericBleService(s.uuid)) ||
930
- services[0];
931
- }
929
+ const service = services.find(s => matchesKnownBleUuid(s.uuid, ONEKEY_SERVICE_UUID_ALIASES));
932
930
  if (!service) {
933
931
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
934
932
  }
935
933
  const selectedService = service;
936
- logger?.info('[NobleBLE] Using service:', selectedService.uuid);
937
-
934
+ logger?.debug('[NobleBLE] service selected', {
935
+ deviceId: peripheral.id,
936
+ serviceUuid: selectedService.uuid,
937
+ });
938
938
  // Step 2: Discover ALL characteristics (no filter)
939
939
  const characteristics = await new Promise<Characteristic[]>((resolve, reject) => {
940
940
  selectedService.discoverCharacteristics([], (error, chars) => {
@@ -948,30 +948,17 @@ async function discoverServicesAndCharacteristics(
948
948
  });
949
949
 
950
950
  // Step 3: Find required characteristics
951
- logger?.info('[NobleBLE] Discovered characteristics:', {
952
- count: characteristics?.length || 0,
953
- uuids: characteristics?.map(c => c.uuid) || [],
954
- });
955
-
956
951
  let writeCharacteristic: Characteristic | null = null;
957
952
  let notifyCharacteristic: Characteristic | null = null;
958
953
 
959
954
  for (const characteristic of characteristics) {
960
- const uuid = characteristic.uuid.replace(/-/g, '').toLowerCase();
961
- const uuidKey = uuid.length >= 8 ? uuid.substring(4, 8) : uuid;
962
-
963
- if (uuidKey === NORMALIZED_WRITE_UUID) {
955
+ if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_WRITE_UUID_ALIASES)) {
964
956
  writeCharacteristic = characteristic;
965
- } else if (uuidKey === NORMALIZED_NOTIFY_UUID) {
957
+ } else if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_NOTIFY_UUID_ALIASES)) {
966
958
  notifyCharacteristic = characteristic;
967
959
  }
968
960
  }
969
961
 
970
- logger?.info('[NobleBLE] Characteristic discovery result:', {
971
- writeFound: !!writeCharacteristic,
972
- notifyFound: !!notifyCharacteristic,
973
- });
974
-
975
962
  if (!writeCharacteristic || !notifyCharacteristic) {
976
963
  logger?.error(
977
964
  '[NobleBLE] Missing characteristics - write:',
@@ -985,6 +972,13 @@ async function discoverServicesAndCharacteristics(
985
972
  );
986
973
  }
987
974
 
975
+ logger?.debug('[NobleBLE] characteristics selected', {
976
+ deviceId: peripheral.id,
977
+ serviceUuid: selectedService.uuid,
978
+ writeUuid: writeCharacteristic.uuid,
979
+ notifyUuid: notifyCharacteristic.uuid,
980
+ });
981
+
988
982
  return { write: writeCharacteristic, notify: notifyCharacteristic };
989
983
  })();
990
984
 
@@ -1017,29 +1011,23 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1017
1011
 
1018
1012
  // Step 2: Force disconnect if connected
1019
1013
  if (peripheral.state === 'connected') {
1020
- // Remove disconnect listener before intentional disconnect to avoid spurious events
1021
- peripheral.removeAllListeners('disconnect');
1022
- await new Promise<void>(resolve => {
1023
- peripheral.disconnect(() => {
1024
- logger?.info('[NobleBLE] Force disconnect completed');
1025
- resolve();
1026
- });
1027
- });
1014
+ await runBleCallbackOperation(
1015
+ callback =>
1016
+ peripheral.disconnect(() => {
1017
+ logger?.info('[NobleBLE] Force disconnect completed');
1018
+ callback();
1019
+ }),
1020
+ { timeoutMs: BLE_CLEANUP_TIMEOUT, timeoutBehavior: 'resolve' }
1021
+ );
1028
1022
  }
1029
1023
 
1030
1024
  // Step 3: Re-establish connection
1031
- await new Promise<void>((resolve, reject) => {
1032
- peripheral.connect((error: Error | undefined) => {
1033
- if (error) {
1034
- logger?.error('[NobleBLE] Force reconnect failed:', error);
1035
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1036
- } else {
1037
- logger?.info('[NobleBLE] Force reconnect successful');
1038
- connectedDevices.set(deviceId, peripheral);
1039
- resolve();
1040
- }
1041
- });
1025
+ await runBleCallbackOperation(callback => peripheral.connect(callback), {
1026
+ timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1027
+ timeoutBehavior: 'reject',
1042
1028
  });
1029
+ logger?.info('[NobleBLE] Force reconnect successful');
1030
+ connectedDevices.set(deviceId, peripheral);
1043
1031
 
1044
1032
  // Wait for connection to stabilize
1045
1033
  await wait(500);
@@ -1170,16 +1158,26 @@ async function setupConnectionAndDiscoverServices(
1170
1158
  deviceId: string,
1171
1159
  webContents: WebContents
1172
1160
  ): Promise<CharacteristicPair> {
1173
- // Force reconnect to clear GATT cache
1174
- await forceReconnectPeripheral(peripheral, deviceId);
1175
1161
  setupDisconnectListener(peripheral, deviceId, webContents);
1176
1162
 
1177
- // Discover services and characteristics with retry
1178
1163
  try {
1179
1164
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1180
- } catch (error) {
1181
- // Last resort: fresh scan to get new peripheral object
1182
- logger?.error('[NobleBLE] Service discovery failed, attempting fresh scan...', error);
1165
+ } catch (initialError) {
1166
+ logger?.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
1167
+ deviceId,
1168
+ error: initialError,
1169
+ });
1170
+ }
1171
+
1172
+ await forceReconnectPeripheral(peripheral, deviceId);
1173
+ setupDisconnectListener(peripheral, deviceId, webContents);
1174
+ try {
1175
+ return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1176
+ } catch (reconnectError) {
1177
+ logger?.error(
1178
+ '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1179
+ reconnectError
1180
+ );
1183
1181
  return freshScanAndDiscover(deviceId, webContents);
1184
1182
  }
1185
1183
  }
@@ -1297,15 +1295,30 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1297
1295
  }
1298
1296
 
1299
1297
  return new Promise((resolve, reject) => {
1298
+ let connectionTimedOut = false;
1300
1299
  const timeout = setTimeout(() => {
1300
+ connectionTimedOut = true;
1301
1301
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
1302
- }, CONNECTION_TIMEOUT);
1302
+ }, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
1303
1303
 
1304
1304
  // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1305
1305
  const connectedPeripheral = peripheral as Peripheral;
1306
1306
  connectedPeripheral.connect(async (error: Error | undefined) => {
1307
1307
  clearTimeout(timeout);
1308
1308
 
1309
+ // Noble may invoke the callback after the SDK timed out and released the request.
1310
+ // Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
1311
+ if (connectionTimedOut) {
1312
+ if (!error) {
1313
+ try {
1314
+ connectedPeripheral.disconnect(() => undefined);
1315
+ } catch {
1316
+ // Best-effort cleanup only.
1317
+ }
1318
+ }
1319
+ return;
1320
+ }
1321
+
1309
1322
  if (error) {
1310
1323
  logger?.error('[NobleBLE] Connection failed:', error);
1311
1324
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
@@ -1342,20 +1355,21 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1342
1355
  return;
1343
1356
  }
1344
1357
 
1345
- return new Promise<void>(resolve => {
1346
- // Remove disconnect listener to avoid triggering handleDeviceDisconnect
1347
- peripheral.removeAllListeners('disconnect');
1348
-
1349
- peripheral.disconnect(() => {
1350
- // Clean up device state using unified function
1351
- cleanupDevice(deviceId, undefined, {
1352
- cleanupConnection: true,
1353
- sendDisconnectEvent: false,
1354
- cancelOperations: true,
1355
- reason: 'manual-disconnect',
1356
- });
1357
- resolve();
1358
- });
1358
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
1359
+ if (disconnectEntry) {
1360
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
1361
+ deviceDisconnectListeners.delete(deviceId);
1362
+ }
1363
+
1364
+ await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1365
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1366
+ timeoutBehavior: 'resolve',
1367
+ });
1368
+ cleanupDevice(deviceId, undefined, {
1369
+ cleanupConnection: true,
1370
+ sendDisconnectEvent: false,
1371
+ cancelOperations: true,
1372
+ reason: 'manual-disconnect',
1359
1373
  });
1360
1374
  }
1361
1375
 
@@ -1376,17 +1390,11 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1376
1390
  subscriptionOperations.set(deviceId, 'unsubscribing');
1377
1391
 
1378
1392
  try {
1379
- await new Promise<void>((resolve, reject) => {
1380
- notifyCharacteristic.unsubscribe((error: Error | undefined) => {
1381
- if (error) {
1382
- logger?.error('[NobleBLE] Notification unsubscription failed:', error);
1383
- reject(error);
1384
- } else {
1385
- logger?.info('[NobleBLE] Notification unsubscription successful');
1386
- resolve();
1387
- }
1388
- });
1393
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1394
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1395
+ timeoutBehavior: 'resolve',
1389
1396
  });
1397
+ logger?.info('[NobleBLE] Notification unsubscription completed');
1390
1398
 
1391
1399
  // Remove all listeners and clear subscription status
1392
1400
  notifyCharacteristic.removeAllListeners('data');
@@ -1479,19 +1487,13 @@ async function subscribeNotifications(
1479
1487
  notifyCharacteristic: Characteristic
1480
1488
  ): Promise<void> {
1481
1489
  // Listeners already cleaned up above, no need to repeat
1482
- await new Promise<void>(resolve => {
1483
- notifyCharacteristic.unsubscribe(() => {
1484
- resolve();
1485
- });
1490
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1491
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1492
+ timeoutBehavior: 'resolve',
1486
1493
  });
1487
- await new Promise<void>((resolve, reject) => {
1488
- notifyCharacteristic.subscribe((error?: Error) => {
1489
- if (error) {
1490
- reject(error);
1491
- return;
1492
- }
1493
- resolve();
1494
- });
1494
+ await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1495
+ timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1496
+ timeoutBehavior: 'reject',
1495
1497
  });
1496
1498
 
1497
1499
  notifyCharacteristic.on('data', (data: Buffer) => {
@@ -1516,29 +1518,25 @@ async function subscribeNotifications(
1516
1518
 
1517
1519
  // Setup IPC handlers
1518
1520
  export function setupNobleBleHandlers(webContents: WebContents): void {
1519
- // Use console.log for initial logging as electron-log might not be available yet.
1520
- console.log('[NobleBLE] Attempting to set up Noble BLE handlers.');
1521
1521
  try {
1522
- console.log('[NobleBLE] NOBLE_VERSION_771');
1523
-
1524
1522
  // @ts-ignore – electron-log is only available at runtime
1525
1523
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1526
1524
  logger = require('electron-log') as Logger;
1527
- console.log('[NobleBLE] electron-log loaded successfully.');
1528
1525
 
1529
1526
  // @ts-ignore – electron is only available at runtime
1530
1527
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1531
1528
  const { ipcMain } = require('electron');
1532
- console.log('[NobleBLE] electron.ipcMain loaded successfully.');
1533
1529
 
1534
1530
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
1535
1531
 
1536
1532
  // Handle enumerate request
1537
- console.log(`[NobleBLE] Registering handler for: ${EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE}`);
1538
1533
  ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1539
1534
  try {
1540
1535
  const devices = await enumerateDevices();
1541
- safeLog(logger, 'info', 'Enumeration completed, devices:', devices);
1536
+ safeLog(logger, 'debug', 'Enumeration completed', {
1537
+ count: devices.length,
1538
+ devices: devices.map(device => ({ id: device.id, name: device.name })),
1539
+ });
1542
1540
  return devices;
1543
1541
  } catch (error) {
1544
1542
  safeLog(logger, 'error', 'Enumeration failed:', error);
@@ -1583,7 +1581,6 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1583
1581
  ipcMain.handle(
1584
1582
  EOneKeyBleMessageKeys.NOBLE_BLE_WRITE,
1585
1583
  async (_event: IpcMainInvokeEvent, deviceId: string, hexData: string) => {
1586
- logger?.info('[NobleBLE] IPC WRITE', { deviceId, len: hexData.length });
1587
1584
  await transmitHexDataToDevice(deviceId, hexData);
1588
1585
  }
1589
1586
  );
@@ -1648,32 +1645,24 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1648
1645
  // Cleanup on app quit
1649
1646
  webContents.on('destroyed', () => {
1650
1647
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1648
+ (async () => {
1649
+ const deviceIds = Array.from(connectedDevices.keys());
1650
+ for (const deviceId of deviceIds) {
1651
+ await unsubscribeNotifications(deviceId).catch(() => undefined);
1652
+ await disconnectDevice(deviceId).catch(() => undefined);
1653
+ }
1651
1654
 
1652
- // 1. Clean up all connected devices (unified cleanup, avoid duplicates)
1653
- const deviceIds = Array.from(connectedDevices.keys());
1654
- deviceIds.forEach(deviceId => {
1655
- cleanupDevice(deviceId, undefined, {
1656
- cleanupConnection: true,
1657
- sendDisconnectEvent: false,
1658
- cancelOperations: true,
1659
- reason: 'app-quit',
1660
- });
1655
+ await stopScanning().catch(() => undefined);
1656
+ if (noble && persistentStateListener) {
1657
+ noble.removeListener('stateChange', persistentStateListener);
1658
+ persistentStateListener = null;
1659
+ }
1660
+ cleanupNobleListeners();
1661
+ discoveredDevices.clear();
1662
+ safeLog(logger, 'info', 'Noble BLE cleanup completed');
1663
+ })().catch(error => {
1664
+ safeLog(logger, 'error', 'Noble BLE cleanup failed', error);
1661
1665
  });
1662
-
1663
- // 2. Stop scanning
1664
- stopScanning();
1665
-
1666
- // 3. Clean up Noble listeners
1667
- if (noble && persistentStateListener) {
1668
- noble.removeListener('stateChange', persistentStateListener);
1669
- persistentStateListener = null;
1670
- }
1671
- cleanupNobleListeners();
1672
-
1673
- // 4. Clear discovered devices cache
1674
- discoveredDevices.clear();
1675
-
1676
- safeLog(logger, 'info', 'Noble BLE cleanup completed');
1677
1666
  });
1678
1667
 
1679
1668
  safeLog(logger, 'info', 'Noble BLE IPC handlers setup completed');