@onekeyfe/hd-transport-electron 1.2.2-alpha.101 → 1.2.2-alpha.103

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.
@@ -40,6 +40,31 @@ import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './type
40
40
  // Noble will be dynamically imported to avoid bundling issues
41
41
  let noble: NobleModule | null = null;
42
42
  let logger: Logger | null = null;
43
+ let disposing = false;
44
+ let nativeReleased = false;
45
+ let disposePromise: Promise<void> | undefined;
46
+ let windowCleanup: Promise<void> | undefined;
47
+ let removeIpcHandlers: (() => void) | undefined;
48
+ const pendingCancellations = new Set<() => void>();
49
+ const nativeConnections = new Set<{ cancel: () => void; settled: Promise<void> }>();
50
+
51
+ function trackNativeConnection(operation: Promise<unknown>, cancel: () => void): void {
52
+ const connection = {
53
+ cancel,
54
+ settled: operation.then(
55
+ () => undefined,
56
+ () => undefined
57
+ ),
58
+ };
59
+ nativeConnections.add(connection);
60
+ connection.settled = connection.settled.finally(() => nativeConnections.delete(connection));
61
+ }
62
+
63
+ function assertBleActive(): void {
64
+ if (disposing) {
65
+ throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down');
66
+ }
67
+ }
43
68
 
44
69
  type NobleBleNativeError = Error & {
45
70
  nativeErrorCode?: number;
@@ -49,7 +74,8 @@ type NobleBleNativeError = Error & {
49
74
  export function createNobleBleConnectionError(error: NobleBleNativeError, messagePrefix = '') {
50
75
  const errorMessage = error.message;
51
76
  const isInvalidMacOsBond =
52
- error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain';
77
+ (error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain') ||
78
+ (error.nativeErrorCode === 15 && error.nativeErrorDomain === 'CBATTErrorDomain');
53
79
  if (isInvalidMacOsBond) {
54
80
  const nativeErrorMessage = `${messagePrefix}${errorMessage}`;
55
81
  return ERRORS.TypedError(
@@ -116,6 +142,102 @@ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null
116
142
  // Device cache and connection state
117
143
  const discoveredDevices = new Map<string, Peripheral>();
118
144
  const connectedDevices = new Map<string, Peripheral>();
145
+ type PendingDeviceConnection = {
146
+ peripheral: Peripheral;
147
+ generation: number;
148
+ cancel: (error: unknown) => void;
149
+ };
150
+ const connectingDevices = new Map<string, PendingDeviceConnection>();
151
+ let nextConnectionGeneration = 0;
152
+
153
+ function connectPeripheralWithCancellation(
154
+ peripheral: Peripheral,
155
+ deviceId: string,
156
+ messagePrefix = ''
157
+ ): Promise<void> {
158
+ assertBleActive();
159
+ return new Promise<void>((resolve, reject) => {
160
+ const generation = ++nextConnectionGeneration;
161
+ let settled = false;
162
+ const settle = (settler: () => void): boolean => {
163
+ if (settled) return false;
164
+ settled = true;
165
+ if (connectingDevices.get(deviceId)?.generation === generation) {
166
+ connectingDevices.delete(deviceId);
167
+ }
168
+ settler();
169
+ return true;
170
+ };
171
+ const pendingConnection: PendingDeviceConnection = {
172
+ peripheral,
173
+ generation,
174
+ cancel: error => {
175
+ settle(() => reject(error));
176
+ },
177
+ };
178
+ connectingDevices
179
+ .get(deviceId)
180
+ ?.cancel(
181
+ ERRORS.TypedError(
182
+ HardwareErrorCode.BleDeviceDisconnected,
183
+ `Device ${deviceId} connect attempt was superseded`
184
+ )
185
+ );
186
+ connectingDevices.set(deviceId, pendingConnection);
187
+
188
+ let completeNative!: () => void;
189
+ trackNativeConnection(
190
+ new Promise<void>(resolve => {
191
+ completeNative = resolve;
192
+ }),
193
+ () => {
194
+ if (typeof peripheral.cancelConnect === 'function') peripheral.cancelConnect();
195
+ else noble?.cancelConnect?.(deviceId);
196
+ }
197
+ );
198
+ const onConnect = (error?: Error | null) => {
199
+ if (nativeReleased) {
200
+ completeNative();
201
+ return;
202
+ }
203
+ if (error) {
204
+ settle(() => reject(createNobleBleConnectionError(error, messagePrefix)));
205
+ completeNative();
206
+ return;
207
+ }
208
+
209
+ if (
210
+ !settle(() => {
211
+ connectedDevices.set(deviceId, peripheral);
212
+ resolve();
213
+ })
214
+ ) {
215
+ const activeConnection = connectingDevices.get(deviceId);
216
+ if (!activeConnection || activeConnection.peripheral !== peripheral) {
217
+ try {
218
+ peripheral.removeAllListeners('disconnect');
219
+ runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
220
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
221
+ timeoutBehavior: 'resolve',
222
+ })
223
+ .catch(() => undefined)
224
+ .finally(completeNative);
225
+ return;
226
+ } catch {
227
+ // Best-effort cleanup for a stale native callback.
228
+ }
229
+ }
230
+ }
231
+ completeNative();
232
+ };
233
+ try {
234
+ peripheral.connect(onConnect);
235
+ } catch (error) {
236
+ completeNative();
237
+ settle(() => reject(error));
238
+ }
239
+ });
240
+ }
119
241
  const pairedDevices = new Set<string>(); // Windows BLE device pairing status tracking
120
242
  const deviceCharacteristics = new Map<string, CharacteristicPair>();
121
243
  const notificationCallbacks = new Map<string, (data: string) => void>();
@@ -320,6 +442,7 @@ function updateBluetoothState(state: string): void {
320
442
 
321
443
  // Initialize Noble
322
444
  async function initializeNoble(): Promise<void> {
445
+ assertBleActive();
323
446
  if (noble) return;
324
447
 
325
448
  try {
@@ -345,12 +468,14 @@ async function initializeNoble(): Promise<void> {
345
468
  }
346
469
 
347
470
  const timeout = setTimeout(() => {
471
+ cleanup();
348
472
  reject(
349
473
  ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
350
474
  );
351
475
  }, BLUETOOTH_INIT_TIMEOUT);
352
476
 
353
477
  const cleanup = () => {
478
+ pendingCancellations.delete(cancel);
354
479
  clearTimeout(timeout);
355
480
  if (noble) {
356
481
  noble.removeListener('stateChange', onStateChange);
@@ -375,9 +500,17 @@ async function initializeNoble(): Promise<void> {
375
500
  }
376
501
  };
377
502
 
503
+ const cancel = () => {
504
+ cleanup();
505
+ reject(
506
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
507
+ );
508
+ };
509
+ pendingCancellations.add(cancel);
378
510
  noble.on('stateChange', onStateChange);
379
511
  });
380
512
 
513
+ assertBleActive();
381
514
  // Set up device discovery
382
515
  if (!persistentDiscoverListener) {
383
516
  persistentDiscoverListener = (peripheral: Peripheral) => {
@@ -462,6 +595,7 @@ function armIdleDisconnect(
462
595
  reason: 'idle' | 'busy-backstop' = 'idle'
463
596
  ): void {
464
597
  clearIdleDisconnect(deviceId);
598
+ if (disposing) return;
465
599
  idleDisconnectTimers.set(
466
600
  deviceId,
467
601
  setTimeout(() => {
@@ -544,6 +678,14 @@ function cleanupDevice(
544
678
 
545
679
  // 1. Clean up connection state
546
680
  if (cleanupConnection) {
681
+ connectingDevices
682
+ .get(deviceId)
683
+ ?.cancel(
684
+ ERRORS.TypedError(
685
+ HardwareErrorCode.BleDeviceDisconnected,
686
+ `Device ${deviceId} disconnected while connecting`
687
+ )
688
+ );
547
689
  const disconnectEntry = deviceDisconnectListeners.get(deviceId);
548
690
  if (disconnectEntry) {
549
691
  disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
@@ -653,6 +795,7 @@ async function writeCharacteristicWithoutResponse(
653
795
  writeCharacteristic: Characteristic,
654
796
  buffer: Buffer
655
797
  ): Promise<void> {
798
+ assertBleActive();
656
799
  return new Promise((resolve, reject) => {
657
800
  writeCharacteristic.write(buffer, true, (error?: Error) => {
658
801
  if (error) {
@@ -937,6 +1080,7 @@ async function performTargetedScan(
937
1080
  targetDeviceId: string,
938
1081
  timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
939
1082
  ): Promise<Peripheral | null> {
1083
+ assertBleActive();
940
1084
  if (!noble) {
941
1085
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
942
1086
  }
@@ -952,6 +1096,7 @@ async function performTargetedScan(
952
1096
  const finish = async (peripheral: Peripheral | null, error?: Error) => {
953
1097
  if (settled) return;
954
1098
  settled = true;
1099
+ pendingCancellations.delete(cancel);
955
1100
  if (timeoutId) clearTimeout(timeoutId);
956
1101
  nobleInstance.removeListener('discover', onDiscover);
957
1102
  await waitForNobleScanStop(nobleInstance);
@@ -961,6 +1106,7 @@ async function performTargetedScan(
961
1106
  reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
962
1107
  return;
963
1108
  }
1109
+ assertBleActive();
964
1110
  if (peripheral) {
965
1111
  discoveredDevices.set(peripheral.id, peripheral);
966
1112
  }
@@ -983,6 +1129,11 @@ async function performTargetedScan(
983
1129
  finish(null).catch(reject);
984
1130
  }, timeoutMs);
985
1131
 
1132
+ const cancel = () => {
1133
+ finish(null, new Error('Noble BLE is shutting down')).catch(reject);
1134
+ };
1135
+ pendingCancellations.add(cancel);
1136
+
986
1137
  // Add local listener for this scan
987
1138
  nobleInstance.on('discover', onDiscover);
988
1139
 
@@ -1008,6 +1159,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1008
1159
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1009
1160
  }
1010
1161
 
1162
+ assertBleActive();
1011
1163
  // Capture noble reference for use in closures (TypeScript narrowing)
1012
1164
  const nobleInstance = noble;
1013
1165
 
@@ -1022,12 +1174,15 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1022
1174
 
1023
1175
  return new Promise((resolve, reject) => {
1024
1176
  const devices: DeviceInfo[] = [];
1177
+ let settled = false;
1025
1178
  let intervalId: ReturnType<typeof setInterval> | undefined;
1026
1179
 
1027
1180
  // Cleanup function: clears timers and waits until Noble confirms scanning
1028
1181
  // has stopped. Resolving enumerate before this callback creates a race with
1029
1182
  // an immediately-following connection attempt.
1030
1183
  const cleanup = async () => {
1184
+ settled = true;
1185
+ pendingCancellations.delete(cancel);
1031
1186
  clearTimeout(timeoutId);
1032
1187
  if (intervalId) clearInterval(intervalId);
1033
1188
  await waitForNobleScanStop(nobleInstance);
@@ -1051,6 +1206,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1051
1206
 
1052
1207
  // Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
1053
1208
  const timeoutId = setTimeout(async () => {
1209
+ if (settled) return;
1054
1210
  // Final collection before resolving — catches devices discovered near the deadline
1055
1211
  checkDevices();
1056
1212
  await cleanup();
@@ -1058,11 +1214,17 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1058
1214
  resolve(devices);
1059
1215
  }, DEVICE_SCAN_TIMEOUT);
1060
1216
 
1217
+ const cancel = () => {
1218
+ cleanup().then(() => resolve([]), reject);
1219
+ };
1220
+ pendingCancellations.add(cancel);
1221
+
1061
1222
  // Start scanning without a service UUID filter so Pro2 advertisements with
1062
1223
  // short vendor UUIDs can be found. Repeated advertisements are required when
1063
1224
  // the local name arrives in a later scan response; discoveredDevices handles deduplication.
1064
1225
  logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
1065
1226
  nobleInstance.startScanning([], true, async (error?: Error) => {
1227
+ if (settled) return;
1066
1228
  if (error) {
1067
1229
  await cleanup();
1068
1230
  logger?.error('[NobleBLE] Failed to start scanning:', error);
@@ -1080,7 +1242,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1080
1242
 
1081
1243
  // Stop scanning
1082
1244
  async function stopScanning(): Promise<void> {
1083
- if (!noble) return;
1245
+ if (!noble || nativeReleased) return;
1084
1246
  const nobleInstance = noble;
1085
1247
  await waitForNobleScanStop(nobleInstance);
1086
1248
  logger?.info('[NobleBLE] Scanning stopped');
@@ -1153,13 +1315,17 @@ function getDevice(deviceId: string): DeviceInfo | null {
1153
1315
  async function discoverServicesAndCharacteristics(
1154
1316
  peripheral: Peripheral
1155
1317
  ): Promise<CharacteristicPair> {
1318
+ assertBleActive();
1156
1319
  // Cleanup resources - will be set up and cleaned in try/finally
1157
1320
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
1158
1321
  let onDisconnect: (() => void) | undefined;
1159
1322
 
1160
1323
  const cleanup = () => {
1161
1324
  if (timeoutId) clearTimeout(timeoutId);
1162
- if (onDisconnect) peripheral.removeListener('disconnect', onDisconnect);
1325
+ if (onDisconnect) {
1326
+ peripheral.removeListener('disconnect', onDisconnect);
1327
+ pendingCancellations.delete(onDisconnect);
1328
+ }
1163
1329
  };
1164
1330
 
1165
1331
  // Racing promises for timeout and disconnect
@@ -1180,6 +1346,7 @@ async function discoverServicesAndCharacteristics(
1180
1346
  )
1181
1347
  );
1182
1348
  };
1349
+ pendingCancellations.add(onDisconnect);
1183
1350
  peripheral.once('disconnect', onDisconnect);
1184
1351
  });
1185
1352
 
@@ -1205,6 +1372,7 @@ async function discoverServicesAndCharacteristics(
1205
1372
  });
1206
1373
  });
1207
1374
 
1375
+ assertBleActive();
1208
1376
  if (!services || services.length === 0) {
1209
1377
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
1210
1378
  }
@@ -1311,17 +1479,8 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1311
1479
  }
1312
1480
 
1313
1481
  // Step 3: Re-establish connection
1314
- await new Promise<void>((resolve, reject) => {
1315
- peripheral.connect(error => {
1316
- if (error) {
1317
- reject(createNobleBleConnectionError(error));
1318
- return;
1319
- }
1320
- resolve();
1321
- });
1322
- });
1482
+ await connectPeripheralWithCancellation(peripheral, deviceId);
1323
1483
  logger?.info('[NobleBLE] Force reconnect successful');
1324
- connectedDevices.set(deviceId, peripheral);
1325
1484
 
1326
1485
  // Wait for connection to stabilize
1327
1486
  await wait(500);
@@ -1357,16 +1516,11 @@ async function freshScanAndDiscover(
1357
1516
  discoveredDevices.set(deviceId, freshPeripheral);
1358
1517
 
1359
1518
  // Connect to fresh peripheral
1360
- await new Promise<void>((resolve, reject) => {
1361
- freshPeripheral.connect((error: Error | undefined) => {
1362
- if (error) {
1363
- reject(createNobleBleConnectionError(error, 'Fresh peripheral connection failed: '));
1364
- } else {
1365
- connectedDevices.set(deviceId, freshPeripheral);
1366
- resolve();
1367
- }
1368
- });
1369
- });
1519
+ await connectPeripheralWithCancellation(
1520
+ freshPeripheral,
1521
+ deviceId,
1522
+ 'Fresh peripheral connection failed: '
1523
+ );
1370
1524
 
1371
1525
  // Setup disconnect listener for fresh peripheral
1372
1526
  setupDisconnectListener(freshPeripheral, deviceId, webContents);
@@ -1438,6 +1592,7 @@ async function discoverServicesAndCharacteristicsWithRetry(
1438
1592
  minTimeout: 500,
1439
1593
  maxTimeout: 3000,
1440
1594
  onFailedAttempt: error => {
1595
+ assertBleActive();
1441
1596
  // This runs after each failed attempt
1442
1597
  logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
1443
1598
  message: error.message,
@@ -1473,13 +1628,17 @@ async function setupConnectionAndDiscoverServices(
1473
1628
  try {
1474
1629
  await forceReconnectPeripheral(peripheral, deviceId);
1475
1630
  } catch (resetError) {
1476
- if (isBleStaleBondHardwareError(resetError)) {
1631
+ if (
1632
+ isBleStaleBondHardwareError(resetError) ||
1633
+ (resetError as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected
1634
+ ) {
1477
1635
  throw resetError;
1478
1636
  }
1479
1637
  // A failed reset must not abort the attempt: discovery on the existing
1480
1638
  // link, then the fresh-scan fallback, still have a chance to recover.
1481
1639
  logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1482
1640
  }
1641
+ assertBleActive();
1483
1642
  setupDisconnectListener(peripheral, deviceId, webContents);
1484
1643
 
1485
1644
  try {
@@ -1522,6 +1681,7 @@ const directConnectCooldownUntil = new Map<string, number>();
1522
1681
  * Ported from the Trezor connector, where it is field-proven.
1523
1682
  */
1524
1683
  async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
1684
+ assertBleActive();
1525
1685
  const nobleInstance = noble as
1526
1686
  | (typeof noble & {
1527
1687
  connectAsync?: (id: string) => Promise<Peripheral | undefined>;
@@ -1532,34 +1692,42 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1532
1692
  const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
1533
1693
  if (Date.now() < cooldownUntil) return undefined;
1534
1694
 
1695
+ let timer: ReturnType<typeof setTimeout> | undefined;
1696
+ let cancel: (() => void) | undefined;
1697
+ let abandoned = false;
1535
1698
  try {
1536
1699
  // The late-orphan guard must attach to THIS pending connect.
1537
1700
  const directPromise = nobleInstance.connectAsync(deviceId);
1701
+ trackNativeConnection(
1702
+ directPromise.then(async late => {
1703
+ if ((!abandoned && !disposing) || nativeReleased) return;
1704
+ const peripheral = late ?? discoveredDevices.get(deviceId);
1705
+ if (peripheral?.state === 'connected' && !connectedDevices.has(deviceId)) {
1706
+ peripheral.removeAllListeners('disconnect');
1707
+ await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1708
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1709
+ timeoutBehavior: 'resolve',
1710
+ });
1711
+ }
1712
+ }),
1713
+ () => nobleInstance.cancelConnect?.(deviceId)
1714
+ );
1538
1715
  const raced = await Promise.race([
1539
1716
  directPromise,
1540
1717
  new Promise<'timeout'>(resolve => {
1541
- setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
1718
+ cancel = () => {
1719
+ abandoned = true;
1720
+ resolve('timeout');
1721
+ };
1722
+ pendingCancellations.add(cancel);
1723
+ timer = setTimeout(cancel, DIRECT_CONNECT_TIMEOUT_MS);
1542
1724
  }),
1543
1725
  ]);
1544
- if (raced === 'timeout') {
1726
+ if (raced === 'timeout' || disposing) {
1545
1727
  directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1546
1728
  logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1547
1729
  deviceId,
1548
1730
  });
1549
- // Promise.race times out the caller only; a late success orphans the link.
1550
- directPromise
1551
- .then(late => {
1552
- const latePeripheral = late ?? discoveredDevices.get(deviceId);
1553
- if (
1554
- latePeripheral &&
1555
- latePeripheral.state === 'connected' &&
1556
- !connectedDevices.has(deviceId)
1557
- ) {
1558
- latePeripheral.removeAllListeners('disconnect');
1559
- latePeripheral.disconnect(() => undefined);
1560
- }
1561
- })
1562
- .catch(() => undefined);
1563
1731
  return undefined;
1564
1732
  }
1565
1733
  // Backends emit `discover` as a side effect, so the cache may hold it.
@@ -1574,6 +1742,9 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1574
1742
  error: String(error),
1575
1743
  });
1576
1744
  return undefined;
1745
+ } finally {
1746
+ clearTimeout(timer);
1747
+ if (cancel) pendingCancellations.delete(cancel);
1577
1748
  }
1578
1749
  }
1579
1750
 
@@ -1644,6 +1815,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1644
1815
  }
1645
1816
  }
1646
1817
 
1818
+ assertBleActive();
1647
1819
  // At this point, peripheral is guaranteed to be defined
1648
1820
  if (!peripheral) {
1649
1821
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
@@ -1666,6 +1838,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1666
1838
  // Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
1667
1839
  // and MTU listeners may still hold the webContents of a soft-restarted
1668
1840
  // renderer, and the reuse fast path below returns before any other setup.
1841
+ assertBleActive();
1669
1842
  setupDisconnectListener(peripheral, deviceId, webContents);
1670
1843
 
1671
1844
  // Check if we already have characteristics for this device
@@ -1716,6 +1889,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1716
1889
  deviceId,
1717
1890
  webContents
1718
1891
  );
1892
+ assertBleActive();
1719
1893
  deviceCharacteristics.set(deviceId, characteristics);
1720
1894
  logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1721
1895
  } catch (setupError) {
@@ -1727,51 +1901,43 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1727
1901
  return;
1728
1902
  }
1729
1903
 
1730
- return new Promise((resolve, reject) => {
1731
- // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1732
- const connectedPeripheral = peripheral as Peripheral;
1733
- connectedPeripheral.connect(async (error: Error | undefined) => {
1734
- if (error) {
1735
- logger?.error('[NobleBLE] Connection failed:', error);
1736
- reject(createNobleBleConnectionError(error));
1737
- return;
1738
- }
1739
-
1740
- logger?.info('[NobleBLE] Connected to device:', deviceId);
1741
- connectedDevices.set(deviceId, connectedPeripheral);
1904
+ const connectedPeripheral = peripheral;
1905
+ await connectPeripheralWithCancellation(connectedPeripheral, deviceId);
1906
+ logger?.info('[NobleBLE] Connected to device:', deviceId);
1742
1907
 
1743
- // Setup connection and discover services
1744
- try {
1745
- const characteristics = await setupConnectionAndDiscoverServices(
1746
- connectedPeripheral,
1747
- deviceId,
1748
- webContents
1749
- );
1750
- deviceCharacteristics.set(deviceId, characteristics);
1751
- logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1752
- resolve();
1753
- } catch (setupError) {
1754
- logger?.error('[NobleBLE] Connection setup failed:', setupError);
1755
- // Never reject from inside a raw disconnect callback: noble only fires
1756
- // it on a real 'disconnect' event, so a peripheral that is already
1757
- // down leaves this promise — and the renderer acquire awaiting it —
1758
- // pending forever. disconnectDevice always settles (it no-ops on an
1759
- // unknown peripheral and caps the confirm wait).
1760
- disconnectDevice(deviceId)
1761
- .catch(() => undefined)
1762
- .then(() => reject(setupError));
1763
- }
1764
- });
1765
- });
1908
+ // Setup connection and discover services
1909
+ try {
1910
+ const characteristics = await setupConnectionAndDiscoverServices(
1911
+ connectedPeripheral,
1912
+ deviceId,
1913
+ webContents
1914
+ );
1915
+ assertBleActive();
1916
+ deviceCharacteristics.set(deviceId, characteristics);
1917
+ logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1918
+ } catch (setupError) {
1919
+ logger?.error('[NobleBLE] Connection setup failed:', setupError);
1920
+ await disconnectDevice(deviceId).catch(() => undefined);
1921
+ throw setupError;
1922
+ }
1766
1923
  }
1767
1924
 
1768
1925
  // Disconnect device
1769
1926
  async function disconnectDevice(deviceId: string): Promise<void> {
1770
- const peripheral = connectedDevices.get(deviceId);
1927
+ if (nativeReleased) return;
1928
+ const pendingConnection = connectingDevices.get(deviceId);
1929
+ const peripheral = connectedDevices.get(deviceId) ?? pendingConnection?.peripheral;
1771
1930
  if (!peripheral) {
1772
1931
  return;
1773
1932
  }
1774
1933
 
1934
+ pendingConnection?.cancel(
1935
+ ERRORS.TypedError(
1936
+ HardwareErrorCode.BleDeviceDisconnected,
1937
+ `Device ${deviceId} connection cancelled`
1938
+ )
1939
+ );
1940
+
1775
1941
  const disconnectEntry = deviceDisconnectListeners.get(deviceId);
1776
1942
  if (disconnectEntry) {
1777
1943
  disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
@@ -1799,6 +1965,7 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1799
1965
 
1800
1966
  // Unsubscribe from notifications
1801
1967
  async function unsubscribeNotifications(deviceId: string): Promise<void> {
1968
+ if (nativeReleased) return;
1802
1969
  const peripheral = connectedDevices.get(deviceId);
1803
1970
  const characteristics = deviceCharacteristics.get(deviceId);
1804
1971
 
@@ -1826,7 +1993,7 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1826
1993
  subscribedDevices.delete(deviceId);
1827
1994
  } finally {
1828
1995
  // 🔒 CRITICAL: Always clear operation state (even on error)
1829
- subscriptionOperations.set(deviceId, 'idle');
1996
+ if (!disposing) subscriptionOperations.set(deviceId, 'idle');
1830
1997
  }
1831
1998
  }
1832
1999
 
@@ -1915,11 +2082,13 @@ async function subscribeNotifications(
1915
2082
  timeoutMs: BLE_CLEANUP_TIMEOUT,
1916
2083
  timeoutBehavior: 'resolve',
1917
2084
  });
2085
+ assertBleActive();
1918
2086
  await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1919
2087
  timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
1920
2088
  timeoutBehavior: 'reject',
1921
2089
  });
1922
2090
 
2091
+ assertBleActive();
1923
2092
  notifyCharacteristic.on('data', (data: Buffer) => {
1924
2093
  // Windows BLE pairing detection: receiving any data means device is paired
1925
2094
  if (!pairedDevices.has(deviceId)) {
@@ -1934,19 +2103,27 @@ async function subscribeNotifications(
1934
2103
  const subscribeStartedAt = Date.now();
1935
2104
  try {
1936
2105
  await rebuildAppSubscription(deviceId, notifyCharacteristic);
2106
+ assertBleActive();
1937
2107
  subscribedDevices.set(deviceId, true);
1938
2108
  logger?.info('[NobleBLE] Notification subscription active', {
1939
2109
  deviceId,
1940
2110
  ms: Date.now() - subscribeStartedAt,
1941
2111
  });
2112
+ } catch (error) {
2113
+ throw createNobleBleConnectionError(
2114
+ error as NobleBleNativeError,
2115
+ 'Notification subscription failed: '
2116
+ );
1942
2117
  } finally {
1943
2118
  // 🔒 CRITICAL: Always clear operation state (even on error)
1944
- subscriptionOperations.set(deviceId, 'idle');
2119
+ if (!disposing) subscriptionOperations.set(deviceId, 'idle');
1945
2120
  }
1946
2121
  }
1947
2122
 
1948
2123
  // Setup IPC handlers
1949
2124
  export function setupNobleBleHandlers(webContents: WebContents): void {
2125
+ if (disposing) return;
2126
+ let windowDestroyed = false;
1950
2127
  try {
1951
2128
  // @ts-ignore – electron-log is only available at runtime
1952
2129
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
@@ -1956,11 +2133,24 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1956
2133
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1957
2134
  const { ipcMain } = require('electron') as { ipcMain: IpcMain };
1958
2135
 
2136
+ const channels = new Set<string>();
2137
+ removeIpcHandlers = () => channels.forEach(channel => ipcMain.removeHandler(channel));
2138
+
1959
2139
  // Electron throws on duplicate channels and setup re-runs on soft restart.
1960
2140
  const handle: IpcMain['handle'] = (channel, listener) => {
2141
+ channels.add(channel);
1961
2142
  ipcMain.removeHandler(channel);
1962
2143
  ipcMain.handle(channel, async (...args) => {
1963
2144
  try {
2145
+ // A replacement renderer must not race the old renderer's shared-state cleanup.
2146
+ if (windowCleanup) await windowCleanup;
2147
+ assertBleActive();
2148
+ if (windowDestroyed) {
2149
+ throw ERRORS.TypedError(
2150
+ HardwareErrorCode.BleDeviceDisconnected,
2151
+ 'BLE window destroyed'
2152
+ );
2153
+ }
1964
2154
  return await Promise.resolve(listener(...args));
1965
2155
  } catch (error) {
1966
2156
  return createNobleBleIpcErrorResponse(error);
@@ -2116,16 +2306,24 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
2116
2306
  }
2117
2307
  });
2118
2308
 
2119
- // Cleanup on app quit
2309
+ // Window cleanup also runs during renderer soft restart; native release belongs to app quit.
2120
2310
  webContents.on('destroyed', () => {
2311
+ windowDestroyed = true;
2312
+ if (disposing) return;
2121
2313
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
2122
- (async () => {
2314
+ const previousCleanup = windowCleanup;
2315
+ windowCleanup = (async () => {
2316
+ if (previousCleanup) await previousCleanup;
2317
+ if (disposing) return;
2123
2318
  const deviceIds = Array.from(connectedDevices.keys());
2124
2319
  for (const deviceId of deviceIds) {
2320
+ if (disposing) return;
2125
2321
  await unsubscribeNotifications(deviceId).catch(() => undefined);
2322
+ if (disposing) return;
2126
2323
  await disconnectDevice(deviceId).catch(() => undefined);
2127
2324
  }
2128
2325
 
2326
+ if (disposing) return;
2129
2327
  await stopScanning().catch(() => undefined);
2130
2328
  // persistentStateListener is process-lifetime, NOT per-window: this
2131
2329
  // destroy handler also fires on a renderer soft restart, and removing
@@ -2147,3 +2345,77 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
2147
2345
  throw error;
2148
2346
  }
2149
2347
  }
2348
+
2349
+ /**
2350
+ * Terminal, bounded cleanup while the Node environment is still alive.
2351
+ * If Noble is shared with another transport, releaseNoble must queue the instance
2352
+ * and call stop() once after every transport has finished its cleanup.
2353
+ */
2354
+ export function disposeNobleBleSupport(
2355
+ releaseNoble: (instance: { stop(): void }) => void = instance => instance.stop()
2356
+ ): Promise<void> {
2357
+ if (disposePromise) return disposePromise;
2358
+ disposing = true;
2359
+ removeIpcHandlers?.();
2360
+ const instance = noble;
2361
+ const deviceIds = new Set([...connectedDevices.keys(), ...connectingDevices.keys()]);
2362
+ const pendingPeripherals = Array.from(connectingDevices.values(), pending => pending.peripheral);
2363
+ for (const cancel of pendingCancellations) cancel();
2364
+ for (const id of idleDisconnectTimers.keys()) clearIdleDisconnect(id);
2365
+ for (const pending of connectingDevices.values()) {
2366
+ pending.cancel(
2367
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
2368
+ );
2369
+ }
2370
+ const connections = Array.from(nativeConnections);
2371
+ for (const connection of connections) {
2372
+ try {
2373
+ connection.cancel();
2374
+ } catch (error) {
2375
+ logger?.warn('[NobleBLE] Native connect cancellation failed', error);
2376
+ }
2377
+ }
2378
+ let timeout: ReturnType<typeof setTimeout> | undefined;
2379
+ disposePromise = (async () => {
2380
+ try {
2381
+ await Promise.race([
2382
+ Promise.allSettled([
2383
+ windowCleanup,
2384
+ ...connections.map(connection => connection.settled),
2385
+ stopScanning(),
2386
+ ...pendingPeripherals.map(peripheral =>
2387
+ runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
2388
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
2389
+ timeoutBehavior: 'resolve',
2390
+ })
2391
+ ),
2392
+ ...Array.from(deviceIds, async id => {
2393
+ await unsubscribeNotifications(id).catch(() => undefined);
2394
+ await disconnectDevice(id).catch(() => undefined);
2395
+ }),
2396
+ ]),
2397
+ new Promise<void>(resolve => {
2398
+ timeout = setTimeout(() => {
2399
+ logger?.warn('[NobleBLE] Process dispose timed out; releasing native manager');
2400
+ resolve();
2401
+ }, 3500);
2402
+ }),
2403
+ ]);
2404
+ } finally {
2405
+ clearTimeout(timeout);
2406
+ // Bounded cleanup may finish before a broken native callback; it must stay inert afterwards.
2407
+ nativeReleased = true;
2408
+ cleanupNobleListeners();
2409
+ if (instance && persistentStateListener) {
2410
+ instance.removeListener('stateChange', persistentStateListener);
2411
+ persistentStateListener = null;
2412
+ }
2413
+ for (const id of deviceIds) cleanupDevice(id);
2414
+ discoveredDevices.clear();
2415
+ directConnectCooldownUntil.clear();
2416
+ if (instance) releaseNoble(instance);
2417
+ logger?.info('[NobleBLE] Process dispose completed');
2418
+ }
2419
+ })();
2420
+ return disposePromise;
2421
+ }