@onekeyfe/hd-transport-electron 1.2.0-alpha.32 → 1.2.0-alpha.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ble-ops.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  /// <reference types="node" />
2
2
  import type { Characteristic } from '@stoprocent/noble';
3
3
  import type { Logger } from './types/noble-extended';
4
+ type BleCallback = (error?: Error) => void;
5
+ export declare function runBleCallbackOperation(operation: (callback: BleCallback) => void, options: {
6
+ timeoutMs: number;
7
+ timeoutBehavior: 'resolve' | 'reject';
8
+ }): Promise<void>;
4
9
  export interface SoftRefreshParams {
5
10
  deviceId: string;
6
11
  notifyCharacteristic: Characteristic | null | undefined;
@@ -11,4 +16,5 @@ export interface SoftRefreshParams {
11
16
  logger: Logger | null;
12
17
  }
13
18
  export declare function softRefreshSubscription(params: SoftRefreshParams): Promise<void>;
19
+ export {};
14
20
  //# sourceMappingURL=ble-ops.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ble-ops.d.ts","sourceRoot":"","sources":["../src/ble-ops.ts"],"names":[],"mappings":";AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAErD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,cAAc,GAAG,IAAI,GAAG,SAAS,CAAC;IACxD,sBAAsB,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,GAAG,eAAe,GAAG,MAAM,CAAC,CAAC;IAC9E,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,kBAAkB,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7D,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA8CtF"}
1
+ {"version":3,"file":"ble-ops.d.ts","sourceRoot":"","sources":["../src/ble-ops.ts"],"names":[],"mappings":";AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAErD,KAAK,WAAW,GAAG,CAAC,KAAK,CAAC,EAAE,KAAK,KAAK,IAAI,CAAC;AAE3C,wBAAgB,uBAAuB,CACrC,SAAS,EAAE,CAAC,QAAQ,EAAE,WAAW,KAAK,IAAI,EAC1C,OAAO,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,SAAS,GAAG,QAAQ,CAAA;CAAE,GACpE,OAAO,CAAC,IAAI,CAAC,CA2Bf;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,cAAc,GAAG,IAAI,GAAG,SAAS,CAAC;IACxD,sBAAsB,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,GAAG,eAAe,GAAG,MAAM,CAAC,CAAC;IAC9E,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACxC,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,kBAAkB,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7D,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA0CtF"}
@@ -32,7 +32,7 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
32
32
 
33
33
  function initNobleBleSupport(webContents) {
34
34
  return __awaiter(this, void 0, void 0, function* () {
35
- const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-16791965.js'); });
35
+ const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-d58af248.js'); });
36
36
  setupNobleBleHandlers(webContents);
37
37
  });
38
38
  }
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
- var index = require('./index-55699e30.js');
5
+ var index = require('./index-5f728113.js');
6
6
 
7
7
 
8
8
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var index = require('./index-55699e30.js');
3
+ var index = require('./index-5f728113.js');
4
4
  var hdShared = require('@onekeyfe/hd-shared');
5
5
  var pRetry = require('p-retry');
6
6
 
@@ -17,6 +17,37 @@ function safeLog(logger, level, message, ...args) {
17
17
  }
18
18
  }
19
19
 
20
+ function runBleCallbackOperation(operation, options) {
21
+ return new Promise((resolve, reject) => {
22
+ let settled = false;
23
+ const settle = (error) => {
24
+ if (settled)
25
+ return;
26
+ settled = true;
27
+ clearTimeout(timeout);
28
+ if (error) {
29
+ reject(error);
30
+ }
31
+ else {
32
+ resolve();
33
+ }
34
+ };
35
+ const timeout = setTimeout(() => {
36
+ if (options.timeoutBehavior === 'reject') {
37
+ settle(new Error(`BLE operation timed out after ${options.timeoutMs}ms`));
38
+ }
39
+ else {
40
+ settle();
41
+ }
42
+ }, options.timeoutMs);
43
+ try {
44
+ operation(settle);
45
+ }
46
+ catch (error) {
47
+ settle(error instanceof Error ? error : new Error(String(error)));
48
+ }
49
+ });
50
+ }
20
51
  function softRefreshSubscription(params) {
21
52
  return index.__awaiter(this, void 0, void 0, function* () {
22
53
  const { deviceId, notifyCharacteristic, subscriptionOperations, subscribedDevices, pairedDevices, onNotificationData, logger, } = params;
@@ -25,17 +56,13 @@ function softRefreshSubscription(params) {
25
56
  }
26
57
  logger === null || logger === void 0 ? void 0 : logger.info('[BLE-OPS] Starting subscription refresh', { deviceId });
27
58
  subscriptionOperations.set(deviceId, 'subscribing');
28
- yield new Promise(resolve => {
29
- notifyCharacteristic.unsubscribe(() => resolve());
59
+ yield runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
60
+ timeoutMs: 250,
61
+ timeoutBehavior: 'resolve',
30
62
  });
31
- yield new Promise((resolve, reject) => {
32
- notifyCharacteristic.subscribe((error) => {
33
- if (error) {
34
- reject(error);
35
- return;
36
- }
37
- resolve();
38
- });
63
+ yield runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
64
+ timeoutMs: 8000,
65
+ timeoutBehavior: 'reject',
39
66
  });
40
67
  notifyCharacteristic.removeAllListeners('data');
41
68
  notifyCharacteristic.on('data', (data) => {
@@ -59,6 +86,7 @@ const bluetoothState = {
59
86
  initialized: false,
60
87
  };
61
88
  let persistentStateListener = null;
89
+ let persistentDiscoverListener = null;
62
90
  const discoveredDevices = new Map();
63
91
  const connectedDevices = new Map();
64
92
  const pairedDevices = new Set();
@@ -66,16 +94,18 @@ const deviceCharacteristics = new Map();
66
94
  const notificationCallbacks = new Map();
67
95
  const subscribedDevices = new Map();
68
96
  const subscriptionOperations = new Map();
97
+ const deviceDisconnectListeners = new Map();
69
98
  const ONEKEY_SERVICE_UUIDS = [hdShared.ONEKEY_SERVICE_UUID];
70
- const PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS = new Set(['fffd']);
71
- const NORMALIZED_WRITE_UUID = '0002';
72
- const NORMALIZED_NOTIFY_UUID = '0003';
99
+ const ONEKEY_SERVICE_UUID_ALIASES = hdShared.createKnownBleUuidAliases(hdShared.ONEKEY_SERVICE_UUID);
100
+ const ONEKEY_WRITE_UUID_ALIASES = hdShared.createKnownBleUuidAliases(hdShared.ONEKEY_WRITE_CHARACTERISTIC_UUID);
101
+ const ONEKEY_NOTIFY_UUID_ALIASES = hdShared.createKnownBleUuidAliases(hdShared.ONEKEY_NOTIFY_CHARACTERISTIC_UUID);
73
102
  const BLUETOOTH_INIT_TIMEOUT = 10000;
74
103
  const DEVICE_SCAN_TIMEOUT = 8000;
75
104
  const FAST_SCAN_TIMEOUT = 8000;
76
105
  const DEVICE_CHECK_INTERVAL = 500;
77
106
  const CONNECTION_TIMEOUT = 8000;
78
107
  const SERVICE_DISCOVERY_TIMEOUT = 10000;
108
+ const BLE_CLEANUP_TIMEOUT = 250;
79
109
  const BLE_PACKET_SIZE = 192;
80
110
  const UNIFIED_WRITE_DELAY = 5;
81
111
  const RETRY_CONFIG = { MAX_ATTEMPTS: 15, WRITE_TIMEOUT: 2000 };
@@ -83,30 +113,15 @@ const IS_WINDOWS = process.platform === 'win32';
83
113
  const ABORTABLE_WRITE_ERROR_PATTERNS = [
84
114
  /status:\s*3/i,
85
115
  ];
86
- function getBleUuidKey(uuid) {
87
- const normalized = (uuid !== null && uuid !== void 0 ? uuid : '').replace(/-/g, '').toLowerCase();
88
- return normalized.length >= 8 ? normalized.substring(4, 8) : normalized;
89
- }
90
- const NORMALIZED_ONEKEY_SERVICE_UUIDS = new Set([
91
- ...ONEKEY_SERVICE_UUIDS.map(uuid => getBleUuidKey(uuid)),
92
- '0001',
93
- ]);
94
- function isGenericBleService(uuid) {
95
- return ['1800', '1801', '180a', '180f'].includes(getBleUuidKey(uuid));
96
- }
97
- function hasOneKeyAdvertisementService(peripheral) {
98
- var _a, _b;
99
- const serviceUuids = (_b = (_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.serviceUuids) !== null && _b !== void 0 ? _b : [];
100
- return serviceUuids.some(uuid => {
101
- const uuidKey = getBleUuidKey(uuid);
102
- return (NORMALIZED_ONEKEY_SERVICE_UUIDS.has(uuidKey) ||
103
- PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(uuidKey));
104
- });
105
- }
106
116
  function isOneKeyPeripheral(peripheral) {
107
- var _a;
108
- const deviceName = ((_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || null;
109
- return hdShared.isOnekeyDevice(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
117
+ var _a, _b;
118
+ const serviceUuids = (_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.serviceUuids;
119
+ return (hdShared.hasOnekeyCommunicationService(serviceUuids) &&
120
+ hdShared.isOnekeyBluetoothDevice({
121
+ id: peripheral.id,
122
+ localName: (_b = peripheral.advertisement) === null || _b === void 0 ? void 0 : _b.localName,
123
+ serviceUuids,
124
+ }));
110
125
  }
111
126
  function emitRawNotification(deviceId, data) {
112
127
  const appCb = notificationCallbacks.get(deviceId);
@@ -245,9 +260,12 @@ function initializeNoble() {
245
260
  };
246
261
  noble.on('stateChange', onStateChange);
247
262
  });
248
- noble.on('discover', (peripheral) => {
249
- handleDeviceDiscovered(peripheral);
250
- });
263
+ if (!persistentDiscoverListener) {
264
+ persistentDiscoverListener = (peripheral) => {
265
+ handleDeviceDiscovered(peripheral);
266
+ };
267
+ noble.on('discover', persistentDiscoverListener);
268
+ }
251
269
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Noble initialized successfully');
252
270
  }
253
271
  catch (error) {
@@ -272,6 +290,11 @@ function cleanupDevice(deviceId, webContents, options = {}) {
272
290
  const peripheral = connectedDevices.get(deviceId);
273
291
  const deviceName = ((_a = peripheral === null || peripheral === void 0 ? void 0 : peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName) || 'Unknown Device';
274
292
  if (cleanupConnection) {
293
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
294
+ if (disconnectEntry) {
295
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
296
+ deviceDisconnectListeners.delete(deviceId);
297
+ }
275
298
  connectedDevices.delete(deviceId);
276
299
  deviceCharacteristics.delete(deviceId);
277
300
  notificationCallbacks.delete(deviceId);
@@ -306,10 +329,15 @@ function handleDeviceDisconnect(deviceId, webContents) {
306
329
  });
307
330
  }
308
331
  function setupDisconnectListener(peripheral, deviceId, webContents) {
309
- peripheral.removeAllListeners('disconnect');
310
- peripheral.on('disconnect', () => {
332
+ const existing = deviceDisconnectListeners.get(deviceId);
333
+ if (existing) {
334
+ existing.peripheral.removeListener('disconnect', existing.listener);
335
+ }
336
+ const listener = () => {
311
337
  handleDeviceDisconnect(deviceId, webContents);
312
- });
338
+ };
339
+ deviceDisconnectListeners.set(deviceId, { peripheral, listener });
340
+ peripheral.on('disconnect', listener);
313
341
  }
314
342
  function writeCharacteristicWithAck(deviceId, writeCharacteristic, buffer) {
315
343
  return index.__awaiter(this, void 0, void 0, function* () {
@@ -470,15 +498,15 @@ function handleDeviceDiscovered(peripheral) {
470
498
  function ensureDiscoverListener() {
471
499
  if (!noble)
472
500
  return;
473
- const listenerCount = noble.listenerCount('discover');
474
- if (listenerCount === 0) {
501
+ if (!persistentDiscoverListener) {
475
502
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Discover listener missing, re-adding it');
476
- noble.on('discover', (peripheral) => {
503
+ persistentDiscoverListener = (peripheral) => {
477
504
  handleDeviceDiscovered(peripheral);
478
- });
505
+ };
506
+ noble.on('discover', persistentDiscoverListener);
479
507
  }
480
508
  else {
481
- logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] Discover listener already exists, count:', listenerCount);
509
+ logger === null || logger === void 0 ? void 0 : logger.debug('[NobleBLE] Discover listener already registered');
482
510
  }
483
511
  }
484
512
  function performTargetedScan(targetDeviceId) {
@@ -491,7 +519,7 @@ function performTargetedScan(targetDeviceId) {
491
519
  return new Promise((resolve, reject) => {
492
520
  const onDiscover = (peripheral) => {
493
521
  var _a;
494
- if (peripheral.id === targetDeviceId) {
522
+ if (peripheral.id === targetDeviceId && isOneKeyPeripheral(peripheral)) {
495
523
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Target device found during targeted scan:', {
496
524
  id: peripheral.id,
497
525
  name: (_a = peripheral.advertisement) === null || _a === void 0 ? void 0 : _a.localName,
@@ -583,25 +611,23 @@ function stopScanning() {
583
611
  return index.__awaiter(this, void 0, void 0, function* () {
584
612
  if (!noble)
585
613
  return;
586
- return new Promise(resolve => {
587
- if (!noble) {
588
- resolve();
589
- return;
590
- }
591
- noble.stopScanning(() => {
592
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Scanning stopped');
593
- resolve();
594
- });
614
+ const nobleInstance = noble;
615
+ yield runBleCallbackOperation(callback => nobleInstance.stopScanning(() => callback()), {
616
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
617
+ timeoutBehavior: 'resolve',
595
618
  });
619
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Scanning stopped');
596
620
  });
597
621
  }
598
622
  function cleanupNobleListeners() {
599
623
  if (!noble)
600
624
  return;
601
625
  try {
602
- noble.removeAllListeners('discover');
603
- noble.removeAllListeners('stateChange');
604
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] All Noble listeners cleaned up');
626
+ if (persistentDiscoverListener) {
627
+ noble.removeListener('discover', persistentDiscoverListener);
628
+ persistentDiscoverListener = null;
629
+ }
630
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Owned Noble listeners cleaned up');
605
631
  }
606
632
  catch (error) {
607
633
  logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Failed to clean up some listeners:', error);
@@ -678,14 +704,7 @@ function discoverServicesAndCharacteristics(peripheral) {
678
704
  deviceId: peripheral.id,
679
705
  serviceUUIDs: services.map(service => service.uuid),
680
706
  });
681
- let service = services.find(s => NORMALIZED_ONEKEY_SERVICE_UUIDS.has(getBleUuidKey(s.uuid)));
682
- if (!service) {
683
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Known OneKey service UUID not found, trying first vendor service');
684
- service =
685
- services.find(s => PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(getBleUuidKey(s.uuid))) ||
686
- services.find(s => !isGenericBleService(s.uuid)) ||
687
- services[0];
688
- }
707
+ const service = services.find(s => hdShared.matchesKnownBleUuid(s.uuid, ONEKEY_SERVICE_UUID_ALIASES));
689
708
  if (!service) {
690
709
  throw hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleServiceNotFound);
691
710
  }
@@ -708,12 +727,10 @@ function discoverServicesAndCharacteristics(peripheral) {
708
727
  let writeCharacteristic = null;
709
728
  let notifyCharacteristic = null;
710
729
  for (const characteristic of characteristics) {
711
- const uuid = characteristic.uuid.replace(/-/g, '').toLowerCase();
712
- const uuidKey = uuid.length >= 8 ? uuid.substring(4, 8) : uuid;
713
- if (uuidKey === NORMALIZED_WRITE_UUID) {
730
+ if (hdShared.matchesKnownBleUuid(characteristic.uuid, ONEKEY_WRITE_UUID_ALIASES)) {
714
731
  writeCharacteristic = characteristic;
715
732
  }
716
- else if (uuidKey === NORMALIZED_NOTIFY_UUID) {
733
+ else if (hdShared.matchesKnownBleUuid(characteristic.uuid, ONEKEY_NOTIFY_UUID_ALIASES)) {
717
734
  notifyCharacteristic = characteristic;
718
735
  }
719
736
  }
@@ -747,27 +764,17 @@ function forceReconnectPeripheral(peripheral, deviceId) {
747
764
  reason: 'force-reconnect',
748
765
  });
749
766
  if (peripheral.state === 'connected') {
750
- peripheral.removeAllListeners('disconnect');
751
- yield new Promise(resolve => {
752
- peripheral.disconnect(() => {
753
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Force disconnect completed');
754
- resolve();
755
- });
756
- });
767
+ yield runBleCallbackOperation(callback => peripheral.disconnect(() => {
768
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Force disconnect completed');
769
+ callback();
770
+ }), { timeoutMs: BLE_CLEANUP_TIMEOUT, timeoutBehavior: 'resolve' });
757
771
  }
758
- yield new Promise((resolve, reject) => {
759
- peripheral.connect((error) => {
760
- if (error) {
761
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Force reconnect failed:', error);
762
- reject(hdShared.ERRORS.TypedError(hdShared.HardwareErrorCode.BleConnectedError, error.message));
763
- }
764
- else {
765
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Force reconnect successful');
766
- connectedDevices.set(deviceId, peripheral);
767
- resolve();
768
- }
769
- });
772
+ yield runBleCallbackOperation(callback => peripheral.connect(callback), {
773
+ timeoutMs: CONNECTION_TIMEOUT,
774
+ timeoutBehavior: 'reject',
770
775
  });
776
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Force reconnect successful');
777
+ connectedDevices.set(deviceId, peripheral);
771
778
  yield hdShared.wait(500);
772
779
  });
773
780
  }
@@ -844,13 +851,23 @@ function discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId) {
844
851
  }
845
852
  function setupConnectionAndDiscoverServices(peripheral, deviceId, webContents) {
846
853
  return index.__awaiter(this, void 0, void 0, function* () {
854
+ setupDisconnectListener(peripheral, deviceId, webContents);
855
+ try {
856
+ return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
857
+ }
858
+ catch (initialError) {
859
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
860
+ deviceId,
861
+ error: initialError,
862
+ });
863
+ }
847
864
  yield forceReconnectPeripheral(peripheral, deviceId);
848
865
  setupDisconnectListener(peripheral, deviceId, webContents);
849
866
  try {
850
867
  return yield discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
851
868
  }
852
- catch (error) {
853
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed, attempting fresh scan...', error);
869
+ catch (reconnectError) {
870
+ logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...', reconnectError);
854
871
  return freshScanAndDiscover(deviceId, webContents);
855
872
  }
856
873
  });
@@ -958,17 +975,20 @@ function disconnectDevice(deviceId) {
958
975
  if (!peripheral) {
959
976
  return;
960
977
  }
961
- return new Promise(resolve => {
962
- peripheral.removeAllListeners('disconnect');
963
- peripheral.disconnect(() => {
964
- cleanupDevice(deviceId, undefined, {
965
- cleanupConnection: true,
966
- sendDisconnectEvent: false,
967
- cancelOperations: true,
968
- reason: 'manual-disconnect',
969
- });
970
- resolve();
971
- });
978
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
979
+ if (disconnectEntry) {
980
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
981
+ deviceDisconnectListeners.delete(deviceId);
982
+ }
983
+ yield runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
984
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
985
+ timeoutBehavior: 'resolve',
986
+ });
987
+ cleanupDevice(deviceId, undefined, {
988
+ cleanupConnection: true,
989
+ sendDisconnectEvent: false,
990
+ cancelOperations: true,
991
+ reason: 'manual-disconnect',
972
992
  });
973
993
  });
974
994
  }
@@ -983,18 +1003,11 @@ function unsubscribeNotifications(deviceId) {
983
1003
  logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Unsubscribing from notifications for device:', deviceId);
984
1004
  subscriptionOperations.set(deviceId, 'unsubscribing');
985
1005
  try {
986
- yield new Promise((resolve, reject) => {
987
- notifyCharacteristic.unsubscribe((error) => {
988
- if (error) {
989
- logger === null || logger === void 0 ? void 0 : logger.error('[NobleBLE] Notification unsubscription failed:', error);
990
- reject(error);
991
- }
992
- else {
993
- logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Notification unsubscription successful');
994
- resolve();
995
- }
996
- });
1006
+ yield runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1007
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1008
+ timeoutBehavior: 'resolve',
997
1009
  });
1010
+ logger === null || logger === void 0 ? void 0 : logger.info('[NobleBLE] Notification unsubscription completed');
998
1011
  notifyCharacteristic.removeAllListeners('data');
999
1012
  notificationCallbacks.delete(deviceId);
1000
1013
  subscribedDevices.delete(deviceId);
@@ -1046,19 +1059,13 @@ function subscribeNotifications(deviceId, callback) {
1046
1059
  notificationCallbacks.set(deviceId, callback);
1047
1060
  function rebuildAppSubscription(deviceId, notifyCharacteristic) {
1048
1061
  return index.__awaiter(this, void 0, void 0, function* () {
1049
- yield new Promise(resolve => {
1050
- notifyCharacteristic.unsubscribe(() => {
1051
- resolve();
1052
- });
1062
+ yield runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1063
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1064
+ timeoutBehavior: 'resolve',
1053
1065
  });
1054
- yield new Promise((resolve, reject) => {
1055
- notifyCharacteristic.subscribe((error) => {
1056
- if (error) {
1057
- reject(error);
1058
- return;
1059
- }
1060
- resolve();
1061
- });
1066
+ yield runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1067
+ timeoutMs: CONNECTION_TIMEOUT,
1068
+ timeoutBehavior: 'reject',
1062
1069
  });
1063
1070
  notifyCharacteristic.on('data', (data) => {
1064
1071
  if (!pairedDevices.has(deviceId)) {
@@ -1158,23 +1165,23 @@ function setupNobleBleHandlers(webContents) {
1158
1165
  }));
1159
1166
  webContents.on('destroyed', () => {
1160
1167
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1161
- const deviceIds = Array.from(connectedDevices.keys());
1162
- deviceIds.forEach(deviceId => {
1163
- cleanupDevice(deviceId, undefined, {
1164
- cleanupConnection: true,
1165
- sendDisconnectEvent: false,
1166
- cancelOperations: true,
1167
- reason: 'app-quit',
1168
- });
1168
+ (() => index.__awaiter(this, void 0, void 0, function* () {
1169
+ const deviceIds = Array.from(connectedDevices.keys());
1170
+ for (const deviceId of deviceIds) {
1171
+ yield unsubscribeNotifications(deviceId).catch(() => undefined);
1172
+ yield disconnectDevice(deviceId).catch(() => undefined);
1173
+ }
1174
+ yield stopScanning().catch(() => undefined);
1175
+ if (noble && persistentStateListener) {
1176
+ noble.removeListener('stateChange', persistentStateListener);
1177
+ persistentStateListener = null;
1178
+ }
1179
+ cleanupNobleListeners();
1180
+ discoveredDevices.clear();
1181
+ safeLog(logger, 'info', 'Noble BLE cleanup completed');
1182
+ }))().catch(error => {
1183
+ safeLog(logger, 'error', 'Noble BLE cleanup failed', error);
1169
1184
  });
1170
- stopScanning();
1171
- if (noble && persistentStateListener) {
1172
- noble.removeListener('stateChange', persistentStateListener);
1173
- persistentStateListener = null;
1174
- }
1175
- cleanupNobleListeners();
1176
- discoveredDevices.clear();
1177
- safeLog(logger, 'info', 'Noble BLE cleanup completed');
1178
1185
  });
1179
1186
  safeLog(logger, 'info', 'Noble BLE IPC handlers setup completed');
1180
1187
  }
@@ -1 +1 @@
1
- {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAsB,WAAW,EAAE,MAAM,UAAU,CAAC;AAw8ChE,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAiKpE"}
1
+ {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAsB,WAAW,EAAE,MAAM,UAAU,CAAC;AA46ChE,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAyJpE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-electron",
3
- "version": "1.2.0-alpha.32",
3
+ "version": "1.2.0-alpha.34",
4
4
  "author": "OneKey",
5
5
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
6
6
  "license": "MIT",
@@ -25,9 +25,9 @@
25
25
  "electron-log": ">=4.0.0"
26
26
  },
27
27
  "dependencies": {
28
- "@onekeyfe/hd-core": "1.2.0-alpha.32",
29
- "@onekeyfe/hd-shared": "1.2.0-alpha.32",
30
- "@onekeyfe/hd-transport": "1.2.0-alpha.32",
28
+ "@onekeyfe/hd-core": "1.2.0-alpha.34",
29
+ "@onekeyfe/hd-shared": "1.2.0-alpha.34",
30
+ "@onekeyfe/hd-transport": "1.2.0-alpha.34",
31
31
  "@stoprocent/noble": "2.3.16",
32
32
  "p-retry": "^4.6.2"
33
33
  },
@@ -36,5 +36,5 @@
36
36
  "electron": "^25.0.0",
37
37
  "typescript": "^5.3.3"
38
38
  },
39
- "gitHead": "468888b93df8b147a512f63a8cbdc40ca1c5c1cf"
39
+ "gitHead": "07f10b4c9fab177da7c36d976ec18b075492b57f"
40
40
  }
@@ -0,0 +1,23 @@
1
+ import { runBleCallbackOperation } from '../ble-ops';
2
+
3
+ describe('Electron Noble callback operations', () => {
4
+ test('settles when Noble never invokes the callback', async () => {
5
+ const result = await runBleCallbackOperation(
6
+ callback => {
7
+ void callback;
8
+ },
9
+ { timeoutMs: 5, timeoutBehavior: 'resolve' }
10
+ );
11
+
12
+ expect(result).toBeUndefined();
13
+ });
14
+
15
+ test('rejects callback errors before the timeout', async () => {
16
+ await expect(
17
+ runBleCallbackOperation(callback => callback(new Error('operation failed')), {
18
+ timeoutMs: 50,
19
+ timeoutBehavior: 'reject',
20
+ })
21
+ ).rejects.toThrow('operation failed');
22
+ });
23
+ });
package/src/ble-ops.ts CHANGED
@@ -1,6 +1,40 @@
1
1
  import type { Characteristic } from '@stoprocent/noble';
2
2
  import type { Logger } from './types/noble-extended';
3
3
 
4
+ type BleCallback = (error?: Error) => void;
5
+
6
+ export function runBleCallbackOperation(
7
+ operation: (callback: BleCallback) => void,
8
+ options: { timeoutMs: number; timeoutBehavior: 'resolve' | 'reject' }
9
+ ): Promise<void> {
10
+ return new Promise<void>((resolve, reject) => {
11
+ let settled = false;
12
+ const settle = (error?: Error) => {
13
+ if (settled) return;
14
+ settled = true;
15
+ clearTimeout(timeout);
16
+ if (error) {
17
+ reject(error);
18
+ } else {
19
+ resolve();
20
+ }
21
+ };
22
+ const timeout = setTimeout(() => {
23
+ if (options.timeoutBehavior === 'reject') {
24
+ settle(new Error(`BLE operation timed out after ${options.timeoutMs}ms`));
25
+ } else {
26
+ settle();
27
+ }
28
+ }, options.timeoutMs);
29
+
30
+ try {
31
+ operation(settle);
32
+ } catch (error) {
33
+ settle(error instanceof Error ? error : new Error(String(error)));
34
+ }
35
+ });
36
+ }
37
+
4
38
  export interface SoftRefreshParams {
5
39
  deviceId: string;
6
40
  notifyCharacteristic: Characteristic | null | undefined;
@@ -30,18 +64,14 @@ export async function softRefreshSubscription(params: SoftRefreshParams): Promis
30
64
 
31
65
  subscriptionOperations.set(deviceId, 'subscribing');
32
66
 
33
- await new Promise<void>(resolve => {
34
- notifyCharacteristic.unsubscribe(() => resolve());
67
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
68
+ timeoutMs: 250,
69
+ timeoutBehavior: 'resolve',
35
70
  });
36
71
 
37
- await new Promise<void>((resolve, reject) => {
38
- notifyCharacteristic.subscribe((error?: Error) => {
39
- if (error) {
40
- reject(error);
41
- return;
42
- }
43
- resolve();
44
- });
72
+ await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
73
+ timeoutMs: 8_000,
74
+ timeoutBehavior: 'reject',
45
75
  });
46
76
 
47
77
  notifyCharacteristic.removeAllListeners('data');
@@ -9,14 +9,19 @@ 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
+ matchesKnownBleUuid,
14
19
  wait,
15
20
  } from '@onekeyfe/hd-shared';
16
21
  import pRetry from 'p-retry';
17
22
 
18
23
  import { safeLog } from './types/noble-extended';
19
- import { softRefreshSubscription } from './ble-ops';
24
+ import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
20
25
 
21
26
  import type { IpcMainInvokeEvent, WebContents } from 'electron';
22
27
  import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
@@ -39,6 +44,7 @@ const bluetoothState: {
39
44
 
40
45
  // Global persistent state listener for app layer
41
46
  let persistentStateListener: ((state: string) => void) | null = null;
47
+ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null;
42
48
 
43
49
  // Device cache and connection state
44
50
  const discoveredDevices = new Map<string, Peripheral>();
@@ -50,6 +56,10 @@ const subscribedDevices = new Map<string, boolean>(); // Track subscription stat
50
56
 
51
57
  // 🔒 Subscription operation state tracking to prevent race conditions
52
58
  const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' | 'idle'>();
59
+ const deviceDisconnectListeners = new Map<
60
+ string,
61
+ { peripheral: Peripheral; listener: () => void }
62
+ >();
53
63
 
54
64
  // Windows-only response watchdog state moved to utils/windows-ble-recovery
55
65
 
@@ -59,11 +69,9 @@ const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' |
59
69
 
60
70
  // Service UUIDs to scan for - using constants from hd-shared
61
71
  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';
72
+ const ONEKEY_SERVICE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_SERVICE_UUID);
73
+ const ONEKEY_WRITE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_WRITE_CHARACTERISTIC_UUID);
74
+ const ONEKEY_NOTIFY_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_NOTIFY_CHARACTERISTIC_UUID);
67
75
 
68
76
  // Timeout and interval constants
69
77
  const BLUETOOTH_INIT_TIMEOUT = 10000; // 10 seconds for Bluetooth initialization
@@ -72,6 +80,7 @@ const FAST_SCAN_TIMEOUT = 8000; // 8 seconds for targeted scanning (Pro2 has lon
72
80
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
73
81
  const CONNECTION_TIMEOUT = 8000; // 8 seconds for device connection (BLE reconnect after release can be slow)
74
82
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
83
+ const BLE_CLEANUP_TIMEOUT = 250;
75
84
 
76
85
  // Write-related constants
77
86
  const BLE_PACKET_SIZE = 192;
@@ -82,34 +91,16 @@ const ABORTABLE_WRITE_ERROR_PATTERNS = [
82
91
  /status:\s*3/i, // Windows pairing cancelled / GATT write failed
83
92
  ];
84
93
 
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
- }
98
-
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
- }
109
-
110
94
  function isOneKeyPeripheral(peripheral: Peripheral) {
111
- const deviceName = peripheral.advertisement?.localName || null;
112
- return isOnekeyDevice(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
95
+ const serviceUuids = peripheral.advertisement?.serviceUuids;
96
+ return (
97
+ hasOnekeyCommunicationService(serviceUuids) &&
98
+ isOnekeyBluetoothDevice({
99
+ id: peripheral.id,
100
+ localName: peripheral.advertisement?.localName,
101
+ serviceUuids,
102
+ })
103
+ );
113
104
  }
114
105
 
115
106
  /**
@@ -290,9 +281,12 @@ async function initializeNoble(): Promise<void> {
290
281
  });
291
282
 
292
283
  // Set up device discovery
293
- noble.on('discover', (peripheral: Peripheral) => {
294
- handleDeviceDiscovered(peripheral);
295
- });
284
+ if (!persistentDiscoverListener) {
285
+ persistentDiscoverListener = (peripheral: Peripheral) => {
286
+ handleDeviceDiscovered(peripheral);
287
+ };
288
+ noble.on('discover', persistentDiscoverListener);
289
+ }
296
290
 
297
291
  logger?.info('[NobleBLE] Noble initialized successfully');
298
292
  } catch (error) {
@@ -354,6 +348,11 @@ function cleanupDevice(
354
348
 
355
349
  // 1. Clean up connection state
356
350
  if (cleanupConnection) {
351
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
352
+ if (disconnectEntry) {
353
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
354
+ deviceDisconnectListeners.delete(deviceId);
355
+ }
357
356
  connectedDevices.delete(deviceId);
358
357
  deviceCharacteristics.delete(deviceId);
359
358
  notificationCallbacks.delete(deviceId);
@@ -403,13 +402,16 @@ function setupDisconnectListener(
403
402
  deviceId: string,
404
403
  webContents: WebContents
405
404
  ): void {
406
- // Remove any existing disconnect listeners to avoid duplicates
407
- peripheral.removeAllListeners('disconnect');
405
+ const existing = deviceDisconnectListeners.get(deviceId);
406
+ if (existing) {
407
+ existing.peripheral.removeListener('disconnect', existing.listener);
408
+ }
408
409
 
409
- // Set up new disconnect listener
410
- peripheral.on('disconnect', () => {
410
+ const listener = () => {
411
411
  handleDeviceDisconnect(deviceId, webContents);
412
- });
412
+ };
413
+ deviceDisconnectListeners.set(deviceId, { peripheral, listener });
414
+ peripheral.on('disconnect', listener);
413
415
  }
414
416
 
415
417
  // ===== Write helpers (inline) =====
@@ -628,16 +630,14 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
628
630
  function ensureDiscoverListener(): void {
629
631
  if (!noble) return;
630
632
 
631
- // Check if discover listener exists by checking listener count
632
- const listenerCount = (noble as any).listenerCount('discover');
633
-
634
- if (listenerCount === 0) {
633
+ if (!persistentDiscoverListener) {
635
634
  logger?.info('[NobleBLE] Discover listener missing, re-adding it');
636
- noble.on('discover', (peripheral: Peripheral) => {
635
+ persistentDiscoverListener = (peripheral: Peripheral) => {
637
636
  handleDeviceDiscovered(peripheral);
638
- });
637
+ };
638
+ noble.on('discover', persistentDiscoverListener);
639
639
  } else {
640
- logger?.debug('[NobleBLE] Discover listener already exists, count:', listenerCount);
640
+ logger?.debug('[NobleBLE] Discover listener already registered');
641
641
  }
642
642
  }
643
643
 
@@ -656,7 +656,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
656
656
  return new Promise((resolve, reject) => {
657
657
  // Local discover listener - only matches target device
658
658
  const onDiscover = (peripheral: Peripheral) => {
659
- if (peripheral.id === targetDeviceId) {
659
+ if (peripheral.id === targetDeviceId && isOneKeyPeripheral(peripheral)) {
660
660
  logger?.info('[NobleBLE] Target device found during targeted scan:', {
661
661
  id: peripheral.id,
662
662
  name: peripheral.advertisement?.localName,
@@ -774,30 +774,24 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
774
774
  // Stop scanning
775
775
  async function stopScanning(): Promise<void> {
776
776
  if (!noble) return;
777
-
778
- return new Promise<void>(resolve => {
779
- if (!noble) {
780
- resolve();
781
- return;
782
- }
783
-
784
- noble.stopScanning(() => {
785
- logger?.info('[NobleBLE] Scanning stopped');
786
- resolve();
787
- });
777
+ const nobleInstance = noble;
778
+ await runBleCallbackOperation(callback => nobleInstance.stopScanning(() => callback()), {
779
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
780
+ timeoutBehavior: 'resolve',
788
781
  });
782
+ logger?.info('[NobleBLE] Scanning stopped');
789
783
  }
790
784
 
791
785
  // Clean up all Noble listeners (for app exit)
792
786
  function cleanupNobleListeners(): void {
793
787
  if (!noble) return;
794
788
 
795
- // Remove all listeners to prevent memory leaks
796
- // Noble uses EventEmitter, need to use removeAllListeners
797
789
  try {
798
- (noble as any).removeAllListeners('discover');
799
- (noble as any).removeAllListeners('stateChange');
800
- logger?.info('[NobleBLE] All Noble listeners cleaned up');
790
+ if (persistentDiscoverListener) {
791
+ noble.removeListener('discover', persistentDiscoverListener);
792
+ persistentDiscoverListener = null;
793
+ }
794
+ logger?.info('[NobleBLE] Owned Noble listeners cleaned up');
801
795
  } catch (error) {
802
796
  logger?.error('[NobleBLE] Failed to clean up some listeners:', error);
803
797
  }
@@ -905,14 +899,7 @@ async function discoverServicesAndCharacteristics(
905
899
  });
906
900
 
907
901
  // Find OneKey service — Noble may expose 128-bit UUIDs as short UUID keys.
908
- let service = services.find(s => NORMALIZED_ONEKEY_SERVICE_UUIDS.has(getBleUuidKey(s.uuid)));
909
- if (!service) {
910
- logger?.info('[NobleBLE] Known OneKey service UUID not found, trying first vendor service');
911
- service =
912
- services.find(s => PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(getBleUuidKey(s.uuid))) ||
913
- services.find(s => !isGenericBleService(s.uuid)) ||
914
- services[0];
915
- }
902
+ const service = services.find(s => matchesKnownBleUuid(s.uuid, ONEKEY_SERVICE_UUID_ALIASES));
916
903
  if (!service) {
917
904
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
918
905
  }
@@ -938,12 +925,9 @@ async function discoverServicesAndCharacteristics(
938
925
  let notifyCharacteristic: Characteristic | null = null;
939
926
 
940
927
  for (const characteristic of characteristics) {
941
- const uuid = characteristic.uuid.replace(/-/g, '').toLowerCase();
942
- const uuidKey = uuid.length >= 8 ? uuid.substring(4, 8) : uuid;
943
-
944
- if (uuidKey === NORMALIZED_WRITE_UUID) {
928
+ if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_WRITE_UUID_ALIASES)) {
945
929
  writeCharacteristic = characteristic;
946
- } else if (uuidKey === NORMALIZED_NOTIFY_UUID) {
930
+ } else if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_NOTIFY_UUID_ALIASES)) {
947
931
  notifyCharacteristic = characteristic;
948
932
  }
949
933
  }
@@ -1000,29 +984,23 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1000
984
 
1001
985
  // Step 2: Force disconnect if connected
1002
986
  if (peripheral.state === 'connected') {
1003
- // Remove disconnect listener before intentional disconnect to avoid spurious events
1004
- peripheral.removeAllListeners('disconnect');
1005
- await new Promise<void>(resolve => {
1006
- peripheral.disconnect(() => {
1007
- logger?.info('[NobleBLE] Force disconnect completed');
1008
- resolve();
1009
- });
1010
- });
987
+ await runBleCallbackOperation(
988
+ callback =>
989
+ peripheral.disconnect(() => {
990
+ logger?.info('[NobleBLE] Force disconnect completed');
991
+ callback();
992
+ }),
993
+ { timeoutMs: BLE_CLEANUP_TIMEOUT, timeoutBehavior: 'resolve' }
994
+ );
1011
995
  }
1012
996
 
1013
997
  // Step 3: Re-establish connection
1014
- await new Promise<void>((resolve, reject) => {
1015
- peripheral.connect((error: Error | undefined) => {
1016
- if (error) {
1017
- logger?.error('[NobleBLE] Force reconnect failed:', error);
1018
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1019
- } else {
1020
- logger?.info('[NobleBLE] Force reconnect successful');
1021
- connectedDevices.set(deviceId, peripheral);
1022
- resolve();
1023
- }
1024
- });
998
+ await runBleCallbackOperation(callback => peripheral.connect(callback), {
999
+ timeoutMs: CONNECTION_TIMEOUT,
1000
+ timeoutBehavior: 'reject',
1025
1001
  });
1002
+ logger?.info('[NobleBLE] Force reconnect successful');
1003
+ connectedDevices.set(deviceId, peripheral);
1026
1004
 
1027
1005
  // Wait for connection to stabilize
1028
1006
  await wait(500);
@@ -1153,16 +1131,26 @@ async function setupConnectionAndDiscoverServices(
1153
1131
  deviceId: string,
1154
1132
  webContents: WebContents
1155
1133
  ): Promise<CharacteristicPair> {
1156
- // Force reconnect to clear GATT cache
1157
- await forceReconnectPeripheral(peripheral, deviceId);
1158
1134
  setupDisconnectListener(peripheral, deviceId, webContents);
1159
1135
 
1160
- // Discover services and characteristics with retry
1161
1136
  try {
1162
1137
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1163
- } catch (error) {
1164
- // Last resort: fresh scan to get new peripheral object
1165
- logger?.error('[NobleBLE] Service discovery failed, attempting fresh scan...', error);
1138
+ } catch (initialError) {
1139
+ logger?.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
1140
+ deviceId,
1141
+ error: initialError,
1142
+ });
1143
+ }
1144
+
1145
+ await forceReconnectPeripheral(peripheral, deviceId);
1146
+ setupDisconnectListener(peripheral, deviceId, webContents);
1147
+ try {
1148
+ return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1149
+ } catch (reconnectError) {
1150
+ logger?.error(
1151
+ '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1152
+ reconnectError
1153
+ );
1166
1154
  return freshScanAndDiscover(deviceId, webContents);
1167
1155
  }
1168
1156
  }
@@ -1325,20 +1313,21 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1325
1313
  return;
1326
1314
  }
1327
1315
 
1328
- return new Promise<void>(resolve => {
1329
- // Remove disconnect listener to avoid triggering handleDeviceDisconnect
1330
- peripheral.removeAllListeners('disconnect');
1331
-
1332
- peripheral.disconnect(() => {
1333
- // Clean up device state using unified function
1334
- cleanupDevice(deviceId, undefined, {
1335
- cleanupConnection: true,
1336
- sendDisconnectEvent: false,
1337
- cancelOperations: true,
1338
- reason: 'manual-disconnect',
1339
- });
1340
- resolve();
1341
- });
1316
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
1317
+ if (disconnectEntry) {
1318
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
1319
+ deviceDisconnectListeners.delete(deviceId);
1320
+ }
1321
+
1322
+ await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1323
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1324
+ timeoutBehavior: 'resolve',
1325
+ });
1326
+ cleanupDevice(deviceId, undefined, {
1327
+ cleanupConnection: true,
1328
+ sendDisconnectEvent: false,
1329
+ cancelOperations: true,
1330
+ reason: 'manual-disconnect',
1342
1331
  });
1343
1332
  }
1344
1333
 
@@ -1359,17 +1348,11 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1359
1348
  subscriptionOperations.set(deviceId, 'unsubscribing');
1360
1349
 
1361
1350
  try {
1362
- await new Promise<void>((resolve, reject) => {
1363
- notifyCharacteristic.unsubscribe((error: Error | undefined) => {
1364
- if (error) {
1365
- logger?.error('[NobleBLE] Notification unsubscription failed:', error);
1366
- reject(error);
1367
- } else {
1368
- logger?.info('[NobleBLE] Notification unsubscription successful');
1369
- resolve();
1370
- }
1371
- });
1351
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1352
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1353
+ timeoutBehavior: 'resolve',
1372
1354
  });
1355
+ logger?.info('[NobleBLE] Notification unsubscription completed');
1373
1356
 
1374
1357
  // Remove all listeners and clear subscription status
1375
1358
  notifyCharacteristic.removeAllListeners('data');
@@ -1462,19 +1445,13 @@ async function subscribeNotifications(
1462
1445
  notifyCharacteristic: Characteristic
1463
1446
  ): Promise<void> {
1464
1447
  // Listeners already cleaned up above, no need to repeat
1465
- await new Promise<void>(resolve => {
1466
- notifyCharacteristic.unsubscribe(() => {
1467
- resolve();
1468
- });
1448
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1449
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1450
+ timeoutBehavior: 'resolve',
1469
1451
  });
1470
- await new Promise<void>((resolve, reject) => {
1471
- notifyCharacteristic.subscribe((error?: Error) => {
1472
- if (error) {
1473
- reject(error);
1474
- return;
1475
- }
1476
- resolve();
1477
- });
1452
+ await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1453
+ timeoutMs: CONNECTION_TIMEOUT,
1454
+ timeoutBehavior: 'reject',
1478
1455
  });
1479
1456
 
1480
1457
  notifyCharacteristic.on('data', (data: Buffer) => {
@@ -1626,32 +1603,24 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1626
1603
  // Cleanup on app quit
1627
1604
  webContents.on('destroyed', () => {
1628
1605
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1606
+ (async () => {
1607
+ const deviceIds = Array.from(connectedDevices.keys());
1608
+ for (const deviceId of deviceIds) {
1609
+ await unsubscribeNotifications(deviceId).catch(() => undefined);
1610
+ await disconnectDevice(deviceId).catch(() => undefined);
1611
+ }
1629
1612
 
1630
- // 1. Clean up all connected devices (unified cleanup, avoid duplicates)
1631
- const deviceIds = Array.from(connectedDevices.keys());
1632
- deviceIds.forEach(deviceId => {
1633
- cleanupDevice(deviceId, undefined, {
1634
- cleanupConnection: true,
1635
- sendDisconnectEvent: false,
1636
- cancelOperations: true,
1637
- reason: 'app-quit',
1638
- });
1613
+ await stopScanning().catch(() => undefined);
1614
+ if (noble && persistentStateListener) {
1615
+ noble.removeListener('stateChange', persistentStateListener);
1616
+ persistentStateListener = null;
1617
+ }
1618
+ cleanupNobleListeners();
1619
+ discoveredDevices.clear();
1620
+ safeLog(logger, 'info', 'Noble BLE cleanup completed');
1621
+ })().catch(error => {
1622
+ safeLog(logger, 'error', 'Noble BLE cleanup failed', error);
1639
1623
  });
1640
-
1641
- // 2. Stop scanning
1642
- stopScanning();
1643
-
1644
- // 3. Clean up Noble listeners
1645
- if (noble && persistentStateListener) {
1646
- noble.removeListener('stateChange', persistentStateListener);
1647
- persistentStateListener = null;
1648
- }
1649
- cleanupNobleListeners();
1650
-
1651
- // 4. Clear discovered devices cache
1652
- discoveredDevices.clear();
1653
-
1654
- safeLog(logger, 'info', 'Noble BLE cleanup completed');
1655
1624
  });
1656
1625
 
1657
1626
  safeLog(logger, 'info', 'Noble BLE IPC handlers setup completed');