@onekeyfe/hd-transport-electron 1.2.2-alpha.9 → 1.2.2

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.
@@ -10,11 +10,13 @@ import {
10
10
  EOneKeyBleMessageKeys,
11
11
  ERRORS,
12
12
  HardwareErrorCode,
13
+ HardwareErrorCodeMessage,
13
14
  ONEKEY_NOTIFY_CHARACTERISTIC_UUID,
14
15
  ONEKEY_SERVICE_UUID,
15
16
  ONEKEY_WRITE_CHARACTERISTIC_UUID,
16
17
  createKnownBleUuidAliases,
17
18
  hasOnekeyCommunicationService,
19
+ isBleStaleBondHardwareError,
18
20
  isOnekeyBluetoothDevice,
19
21
  isPro2FamilyBleName,
20
22
  matchesKnownBleUuid,
@@ -26,18 +28,102 @@ import { resolveBlePacketCapacity, resolveNobleAttMtu } from './ble-packet-capac
26
28
  import { safeLog } from './types/noble-extended';
27
29
  import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
28
30
  import {
29
- NOBLE_BLE_CONNECTION_TIMEOUT_MS,
31
+ NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
30
32
  NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
31
33
  } from './noble-ble-timeouts';
32
34
 
33
35
  import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
34
36
  import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
35
- import type { NobleBleWriteOptions } from './types/desktop-api';
37
+ import type { NobleBleIpcErrorResponse, NobleBleWriteOptions } from './types/desktop-api';
36
38
  import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
37
39
 
38
40
  // Noble will be dynamically imported to avoid bundling issues
39
41
  let noble: NobleModule | null = null;
40
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 pendingEnumerations = new Set<() => Promise<void>>();
50
+ const nativeConnections = new Set<{ cancel: () => void; settled: Promise<void> }>();
51
+
52
+ function trackNativeConnection(operation: Promise<unknown>, cancel: () => void): void {
53
+ const connection = {
54
+ cancel,
55
+ settled: operation.then(
56
+ () => undefined,
57
+ () => undefined
58
+ ),
59
+ };
60
+ nativeConnections.add(connection);
61
+ connection.settled = connection.settled.finally(() => nativeConnections.delete(connection));
62
+ }
63
+
64
+ function assertBleActive(): void {
65
+ if (disposing) {
66
+ throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down');
67
+ }
68
+ }
69
+
70
+ type NobleBleNativeError = Error & {
71
+ nativeErrorCode?: number;
72
+ nativeErrorDomain?: string;
73
+ };
74
+
75
+ export function createNobleBleConnectionError(error: NobleBleNativeError, messagePrefix = '') {
76
+ const errorMessage = error.message;
77
+ const isInvalidMacOsBond =
78
+ (error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain') ||
79
+ (error.nativeErrorCode === 15 && error.nativeErrorDomain === 'CBATTErrorDomain');
80
+ if (isInvalidMacOsBond) {
81
+ const nativeErrorMessage = `${messagePrefix}${errorMessage}`;
82
+ return ERRORS.TypedError(
83
+ HardwareErrorCode.BleBondInvalid,
84
+ `${HardwareErrorCodeMessage[HardwareErrorCode.BleBondInvalid]} (${nativeErrorMessage})`,
85
+ {
86
+ nativeErrorMessage,
87
+ }
88
+ );
89
+ }
90
+
91
+ return ERRORS.TypedError(HardwareErrorCode.BleConnectedError, `${messagePrefix}${errorMessage}`);
92
+ }
93
+
94
+ export function createNobleBleIpcErrorResponse(error: unknown): NobleBleIpcErrorResponse {
95
+ const candidate = error as {
96
+ errorCode?: unknown;
97
+ message?: unknown;
98
+ name?: unknown;
99
+ params?: unknown;
100
+ };
101
+ const errorCode =
102
+ typeof candidate?.errorCode === 'number' ? candidate.errorCode : HardwareErrorCode.UnknownError;
103
+ const message =
104
+ typeof candidate?.message === 'string' ? candidate.message : String(error ?? 'Unknown error');
105
+ const name = typeof candidate?.name === 'string' ? candidate.name : 'Error';
106
+ let params: unknown;
107
+ if (candidate?.params !== undefined) {
108
+ try {
109
+ const serializedParams = JSON.stringify(candidate.params);
110
+ params = serializedParams === undefined ? undefined : JSON.parse(serializedParams);
111
+ } catch {
112
+ params = undefined;
113
+ }
114
+ }
115
+
116
+ return {
117
+ type: 'NobleBleIpcError',
118
+ success: false,
119
+ error: {
120
+ name,
121
+ message,
122
+ errorCode,
123
+ ...(params !== undefined ? { params } : {}),
124
+ },
125
+ };
126
+ }
41
127
 
42
128
  // Bluetooth state management
43
129
  const bluetoothState: {
@@ -57,6 +143,102 @@ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null
57
143
  // Device cache and connection state
58
144
  const discoveredDevices = new Map<string, Peripheral>();
59
145
  const connectedDevices = new Map<string, Peripheral>();
146
+ type PendingDeviceConnection = {
147
+ peripheral: Peripheral;
148
+ generation: number;
149
+ cancel: (error: unknown) => void;
150
+ };
151
+ const connectingDevices = new Map<string, PendingDeviceConnection>();
152
+ let nextConnectionGeneration = 0;
153
+
154
+ function connectPeripheralWithCancellation(
155
+ peripheral: Peripheral,
156
+ deviceId: string,
157
+ messagePrefix = ''
158
+ ): Promise<void> {
159
+ assertBleActive();
160
+ return new Promise<void>((resolve, reject) => {
161
+ const generation = ++nextConnectionGeneration;
162
+ let settled = false;
163
+ const settle = (settler: () => void): boolean => {
164
+ if (settled) return false;
165
+ settled = true;
166
+ if (connectingDevices.get(deviceId)?.generation === generation) {
167
+ connectingDevices.delete(deviceId);
168
+ }
169
+ settler();
170
+ return true;
171
+ };
172
+ const pendingConnection: PendingDeviceConnection = {
173
+ peripheral,
174
+ generation,
175
+ cancel: error => {
176
+ settle(() => reject(error));
177
+ },
178
+ };
179
+ connectingDevices
180
+ .get(deviceId)
181
+ ?.cancel(
182
+ ERRORS.TypedError(
183
+ HardwareErrorCode.BleDeviceDisconnected,
184
+ `Device ${deviceId} connect attempt was superseded`
185
+ )
186
+ );
187
+ connectingDevices.set(deviceId, pendingConnection);
188
+
189
+ let completeNative!: () => void;
190
+ trackNativeConnection(
191
+ new Promise<void>(resolve => {
192
+ completeNative = resolve;
193
+ }),
194
+ () => {
195
+ if (typeof peripheral.cancelConnect === 'function') peripheral.cancelConnect();
196
+ else noble?.cancelConnect?.(deviceId);
197
+ }
198
+ );
199
+ const onConnect = (error?: Error | null) => {
200
+ if (nativeReleased) {
201
+ completeNative();
202
+ return;
203
+ }
204
+ if (error) {
205
+ settle(() => reject(createNobleBleConnectionError(error, messagePrefix)));
206
+ completeNative();
207
+ return;
208
+ }
209
+
210
+ if (
211
+ !settle(() => {
212
+ connectedDevices.set(deviceId, peripheral);
213
+ resolve();
214
+ })
215
+ ) {
216
+ const activeConnection = connectingDevices.get(deviceId);
217
+ if (!activeConnection || activeConnection.peripheral !== peripheral) {
218
+ try {
219
+ peripheral.removeAllListeners('disconnect');
220
+ runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
221
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
222
+ timeoutBehavior: 'resolve',
223
+ })
224
+ .catch(() => undefined)
225
+ .finally(completeNative);
226
+ return;
227
+ } catch {
228
+ // Best-effort cleanup for a stale native callback.
229
+ }
230
+ }
231
+ }
232
+ completeNative();
233
+ };
234
+ try {
235
+ peripheral.connect(onConnect);
236
+ } catch (error) {
237
+ completeNative();
238
+ settle(() => reject(error));
239
+ }
240
+ });
241
+ }
60
242
  const pairedDevices = new Set<string>(); // Windows BLE device pairing status tracking
61
243
  const deviceCharacteristics = new Map<string, CharacteristicPair>();
62
244
  const notificationCallbacks = new Map<string, (data: string) => void>();
@@ -261,6 +443,7 @@ function updateBluetoothState(state: string): void {
261
443
 
262
444
  // Initialize Noble
263
445
  async function initializeNoble(): Promise<void> {
446
+ assertBleActive();
264
447
  if (noble) return;
265
448
 
266
449
  try {
@@ -286,12 +469,14 @@ async function initializeNoble(): Promise<void> {
286
469
  }
287
470
 
288
471
  const timeout = setTimeout(() => {
472
+ cleanup();
289
473
  reject(
290
474
  ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
291
475
  );
292
476
  }, BLUETOOTH_INIT_TIMEOUT);
293
477
 
294
478
  const cleanup = () => {
479
+ pendingCancellations.delete(cancel);
295
480
  clearTimeout(timeout);
296
481
  if (noble) {
297
482
  noble.removeListener('stateChange', onStateChange);
@@ -316,9 +501,17 @@ async function initializeNoble(): Promise<void> {
316
501
  }
317
502
  };
318
503
 
504
+ const cancel = () => {
505
+ cleanup();
506
+ reject(
507
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
508
+ );
509
+ };
510
+ pendingCancellations.add(cancel);
319
511
  noble.on('stateChange', onStateChange);
320
512
  });
321
513
 
514
+ assertBleActive();
322
515
  // Set up device discovery
323
516
  if (!persistentDiscoverListener) {
324
517
  persistentDiscoverListener = (peripheral: Peripheral) => {
@@ -403,6 +596,7 @@ function armIdleDisconnect(
403
596
  reason: 'idle' | 'busy-backstop' = 'idle'
404
597
  ): void {
405
598
  clearIdleDisconnect(deviceId);
599
+ if (disposing) return;
406
600
  idleDisconnectTimers.set(
407
601
  deviceId,
408
602
  setTimeout(() => {
@@ -485,6 +679,14 @@ function cleanupDevice(
485
679
 
486
680
  // 1. Clean up connection state
487
681
  if (cleanupConnection) {
682
+ connectingDevices
683
+ .get(deviceId)
684
+ ?.cancel(
685
+ ERRORS.TypedError(
686
+ HardwareErrorCode.BleDeviceDisconnected,
687
+ `Device ${deviceId} disconnected while connecting`
688
+ )
689
+ );
488
690
  const disconnectEntry = deviceDisconnectListeners.get(deviceId);
489
691
  if (disconnectEntry) {
490
692
  disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
@@ -594,6 +796,7 @@ async function writeCharacteristicWithoutResponse(
594
796
  writeCharacteristic: Characteristic,
595
797
  buffer: Buffer
596
798
  ): Promise<void> {
799
+ assertBleActive();
597
800
  return new Promise((resolve, reject) => {
598
801
  writeCharacteristic.write(buffer, true, (error?: Error) => {
599
802
  if (error) {
@@ -878,6 +1081,7 @@ async function performTargetedScan(
878
1081
  targetDeviceId: string,
879
1082
  timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
880
1083
  ): Promise<Peripheral | null> {
1084
+ assertBleActive();
881
1085
  if (!noble) {
882
1086
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
883
1087
  }
@@ -893,6 +1097,7 @@ async function performTargetedScan(
893
1097
  const finish = async (peripheral: Peripheral | null, error?: Error) => {
894
1098
  if (settled) return;
895
1099
  settled = true;
1100
+ pendingCancellations.delete(cancel);
896
1101
  if (timeoutId) clearTimeout(timeoutId);
897
1102
  nobleInstance.removeListener('discover', onDiscover);
898
1103
  await waitForNobleScanStop(nobleInstance);
@@ -902,6 +1107,7 @@ async function performTargetedScan(
902
1107
  reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
903
1108
  return;
904
1109
  }
1110
+ assertBleActive();
905
1111
  if (peripheral) {
906
1112
  discoveredDevices.set(peripheral.id, peripheral);
907
1113
  }
@@ -924,6 +1130,11 @@ async function performTargetedScan(
924
1130
  finish(null).catch(reject);
925
1131
  }, timeoutMs);
926
1132
 
1133
+ const cancel = () => {
1134
+ finish(null, new Error('Noble BLE is shutting down')).catch(reject);
1135
+ };
1136
+ pendingCancellations.add(cancel);
1137
+
927
1138
  // Add local listener for this scan
928
1139
  nobleInstance.on('discover', onDiscover);
929
1140
 
@@ -940,7 +1151,7 @@ async function performTargetedScan(
940
1151
  }
941
1152
 
942
1153
  // Enumerate devices
943
- async function enumerateDevices(): Promise<DeviceInfo[]> {
1154
+ async function enumerateDevices(isWindowDestroyed: () => boolean): Promise<DeviceInfo[]> {
944
1155
  if (!noble) {
945
1156
  await initializeNoble();
946
1157
  }
@@ -949,13 +1160,14 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
949
1160
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
950
1161
  }
951
1162
 
1163
+ assertBleActive();
1164
+ if (isWindowDestroyed()) return [];
952
1165
  // Capture noble reference for use in closures (TypeScript narrowing)
953
1166
  const nobleInstance = noble;
954
1167
 
955
1168
  logger?.info('[NobleBLE] Starting device enumeration');
956
1169
 
957
- // Clear previous discoveries
958
- discoveredDevices.clear();
1170
+ // Keep prior discoveries when a polling round misses an advertisement.
959
1171
 
960
1172
  // Ensure discover listener is properly set up before scanning
961
1173
  // This is crucial to fix the issue where devices are not found after web-usb failures
@@ -963,15 +1175,24 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
963
1175
 
964
1176
  return new Promise((resolve, reject) => {
965
1177
  const devices: DeviceInfo[] = [];
1178
+ let settled = false;
966
1179
  let intervalId: ReturnType<typeof setInterval> | undefined;
1180
+ let cleanupPromise: Promise<void> | undefined;
967
1181
 
968
1182
  // Cleanup function: clears timers and waits until Noble confirms scanning
969
1183
  // has stopped. Resolving enumerate before this callback creates a race with
970
1184
  // an immediately-following connection attempt.
971
- const cleanup = async () => {
972
- clearTimeout(timeoutId);
973
- if (intervalId) clearInterval(intervalId);
974
- await waitForNobleScanStop(nobleInstance);
1185
+ const cleanup = () => {
1186
+ if (!cleanupPromise) {
1187
+ settled = true;
1188
+ pendingCancellations.delete(cancel);
1189
+ clearTimeout(timeoutId);
1190
+ if (intervalId) clearInterval(intervalId);
1191
+ cleanupPromise = waitForNobleScanStop(nobleInstance).finally(() => {
1192
+ pendingEnumerations.delete(cancel);
1193
+ });
1194
+ }
1195
+ return cleanupPromise;
975
1196
  };
976
1197
 
977
1198
  // Collect discovered devices into the devices array
@@ -992,6 +1213,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
992
1213
 
993
1214
  // Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
994
1215
  const timeoutId = setTimeout(async () => {
1216
+ if (settled) return;
995
1217
  // Final collection before resolving — catches devices discovered near the deadline
996
1218
  checkDevices();
997
1219
  await cleanup();
@@ -999,11 +1221,16 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
999
1221
  resolve(devices);
1000
1222
  }, DEVICE_SCAN_TIMEOUT);
1001
1223
 
1224
+ const cancel = () => cleanup().then(() => resolve([]), reject);
1225
+ pendingCancellations.add(cancel);
1226
+ pendingEnumerations.add(cancel);
1227
+
1002
1228
  // Start scanning without a service UUID filter so Pro2 advertisements with
1003
1229
  // short vendor UUIDs can be found. Repeated advertisements are required when
1004
1230
  // the local name arrives in a later scan response; discoveredDevices handles deduplication.
1005
1231
  logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
1006
1232
  nobleInstance.startScanning([], true, async (error?: Error) => {
1233
+ if (settled) return;
1007
1234
  if (error) {
1008
1235
  await cleanup();
1009
1236
  logger?.error('[NobleBLE] Failed to start scanning:', error);
@@ -1021,7 +1248,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1021
1248
 
1022
1249
  // Stop scanning
1023
1250
  async function stopScanning(): Promise<void> {
1024
- if (!noble) return;
1251
+ if (!noble || nativeReleased) return;
1025
1252
  const nobleInstance = noble;
1026
1253
  await waitForNobleScanStop(nobleInstance);
1027
1254
  logger?.info('[NobleBLE] Scanning stopped');
@@ -1094,13 +1321,17 @@ function getDevice(deviceId: string): DeviceInfo | null {
1094
1321
  async function discoverServicesAndCharacteristics(
1095
1322
  peripheral: Peripheral
1096
1323
  ): Promise<CharacteristicPair> {
1324
+ assertBleActive();
1097
1325
  // Cleanup resources - will be set up and cleaned in try/finally
1098
1326
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
1099
1327
  let onDisconnect: (() => void) | undefined;
1100
1328
 
1101
1329
  const cleanup = () => {
1102
1330
  if (timeoutId) clearTimeout(timeoutId);
1103
- if (onDisconnect) peripheral.removeListener('disconnect', onDisconnect);
1331
+ if (onDisconnect) {
1332
+ peripheral.removeListener('disconnect', onDisconnect);
1333
+ pendingCancellations.delete(onDisconnect);
1334
+ }
1104
1335
  };
1105
1336
 
1106
1337
  // Racing promises for timeout and disconnect
@@ -1121,6 +1352,7 @@ async function discoverServicesAndCharacteristics(
1121
1352
  )
1122
1353
  );
1123
1354
  };
1355
+ pendingCancellations.add(onDisconnect);
1124
1356
  peripheral.once('disconnect', onDisconnect);
1125
1357
  });
1126
1358
 
@@ -1146,6 +1378,7 @@ async function discoverServicesAndCharacteristics(
1146
1378
  });
1147
1379
  });
1148
1380
 
1381
+ assertBleActive();
1149
1382
  if (!services || services.length === 0) {
1150
1383
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
1151
1384
  }
@@ -1252,12 +1485,8 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1252
1485
  }
1253
1486
 
1254
1487
  // Step 3: Re-establish connection
1255
- await runBleCallbackOperation(callback => peripheral.connect(callback), {
1256
- timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1257
- timeoutBehavior: 'reject',
1258
- });
1488
+ await connectPeripheralWithCancellation(peripheral, deviceId);
1259
1489
  logger?.info('[NobleBLE] Force reconnect successful');
1260
- connectedDevices.set(deviceId, peripheral);
1261
1490
 
1262
1491
  // Wait for connection to stabilize
1263
1492
  await wait(500);
@@ -1293,21 +1522,11 @@ async function freshScanAndDiscover(
1293
1522
  discoveredDevices.set(deviceId, freshPeripheral);
1294
1523
 
1295
1524
  // Connect to fresh peripheral
1296
- await new Promise<void>((resolve, reject) => {
1297
- freshPeripheral.connect((error: Error | undefined) => {
1298
- if (error) {
1299
- reject(
1300
- ERRORS.TypedError(
1301
- HardwareErrorCode.BleConnectedError,
1302
- `Fresh peripheral connection failed: ${error.message}`
1303
- )
1304
- );
1305
- } else {
1306
- connectedDevices.set(deviceId, freshPeripheral);
1307
- resolve();
1308
- }
1309
- });
1310
- });
1525
+ await connectPeripheralWithCancellation(
1526
+ freshPeripheral,
1527
+ deviceId,
1528
+ 'Fresh peripheral connection failed: '
1529
+ );
1311
1530
 
1312
1531
  // Setup disconnect listener for fresh peripheral
1313
1532
  setupDisconnectListener(freshPeripheral, deviceId, webContents);
@@ -1379,6 +1598,7 @@ async function discoverServicesAndCharacteristicsWithRetry(
1379
1598
  minTimeout: 500,
1380
1599
  maxTimeout: 3000,
1381
1600
  onFailedAttempt: error => {
1601
+ assertBleActive();
1382
1602
  // This runs after each failed attempt
1383
1603
  logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
1384
1604
  message: error.message,
@@ -1414,10 +1634,17 @@ async function setupConnectionAndDiscoverServices(
1414
1634
  try {
1415
1635
  await forceReconnectPeripheral(peripheral, deviceId);
1416
1636
  } catch (resetError) {
1637
+ if (
1638
+ isBleStaleBondHardwareError(resetError) ||
1639
+ (resetError as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected
1640
+ ) {
1641
+ throw resetError;
1642
+ }
1417
1643
  // A failed reset must not abort the attempt: discovery on the existing
1418
1644
  // link, then the fresh-scan fallback, still have a chance to recover.
1419
1645
  logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1420
1646
  }
1647
+ assertBleActive();
1421
1648
  setupDisconnectListener(peripheral, deviceId, webContents);
1422
1649
 
1423
1650
  try {
@@ -1460,6 +1687,7 @@ const directConnectCooldownUntil = new Map<string, number>();
1460
1687
  * Ported from the Trezor connector, where it is field-proven.
1461
1688
  */
1462
1689
  async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
1690
+ assertBleActive();
1463
1691
  const nobleInstance = noble as
1464
1692
  | (typeof noble & {
1465
1693
  connectAsync?: (id: string) => Promise<Peripheral | undefined>;
@@ -1470,34 +1698,42 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1470
1698
  const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
1471
1699
  if (Date.now() < cooldownUntil) return undefined;
1472
1700
 
1701
+ let timer: ReturnType<typeof setTimeout> | undefined;
1702
+ let cancel: (() => void) | undefined;
1703
+ let abandoned = false;
1473
1704
  try {
1474
1705
  // The late-orphan guard must attach to THIS pending connect.
1475
1706
  const directPromise = nobleInstance.connectAsync(deviceId);
1707
+ trackNativeConnection(
1708
+ directPromise.then(async late => {
1709
+ if ((!abandoned && !disposing) || nativeReleased) return;
1710
+ const peripheral = late ?? discoveredDevices.get(deviceId);
1711
+ if (peripheral?.state === 'connected' && !connectedDevices.has(deviceId)) {
1712
+ peripheral.removeAllListeners('disconnect');
1713
+ await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1714
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1715
+ timeoutBehavior: 'resolve',
1716
+ });
1717
+ }
1718
+ }),
1719
+ () => nobleInstance.cancelConnect?.(deviceId)
1720
+ );
1476
1721
  const raced = await Promise.race([
1477
1722
  directPromise,
1478
1723
  new Promise<'timeout'>(resolve => {
1479
- setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
1724
+ cancel = () => {
1725
+ abandoned = true;
1726
+ resolve('timeout');
1727
+ };
1728
+ pendingCancellations.add(cancel);
1729
+ timer = setTimeout(cancel, DIRECT_CONNECT_TIMEOUT_MS);
1480
1730
  }),
1481
1731
  ]);
1482
- if (raced === 'timeout') {
1732
+ if (raced === 'timeout' || disposing) {
1483
1733
  directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1484
1734
  logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1485
1735
  deviceId,
1486
1736
  });
1487
- // Promise.race times out the caller only; a late success orphans the link.
1488
- directPromise
1489
- .then(late => {
1490
- const latePeripheral = late ?? discoveredDevices.get(deviceId);
1491
- if (
1492
- latePeripheral &&
1493
- latePeripheral.state === 'connected' &&
1494
- !connectedDevices.has(deviceId)
1495
- ) {
1496
- latePeripheral.removeAllListeners('disconnect');
1497
- latePeripheral.disconnect(() => undefined);
1498
- }
1499
- })
1500
- .catch(() => undefined);
1501
1737
  return undefined;
1502
1738
  }
1503
1739
  // Backends emit `discover` as a side effect, so the cache may hold it.
@@ -1512,6 +1748,9 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1512
1748
  error: String(error),
1513
1749
  });
1514
1750
  return undefined;
1751
+ } finally {
1752
+ clearTimeout(timer);
1753
+ if (cancel) pendingCancellations.delete(cancel);
1515
1754
  }
1516
1755
  }
1517
1756
 
@@ -1526,8 +1765,12 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1526
1765
  totalConnected: connectedDevices.size,
1527
1766
  });
1528
1767
 
1529
- // enumerate clears the discovery map; a kept-alive link outlives it.
1530
- let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1768
+ // Retained discoveries are display metadata, not proof of a reusable native link.
1769
+ // Prefer the live connection when an older discovery exists for the same device.
1770
+ let peripheral = connectedDevices.get(deviceId) ?? discoveredDevices.get(deviceId);
1771
+ if (peripheral?.state !== 'connected') {
1772
+ peripheral = undefined;
1773
+ }
1531
1774
 
1532
1775
  if (!peripheral) {
1533
1776
  // Initialize Noble if not already done
@@ -1582,6 +1825,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1582
1825
  }
1583
1826
  }
1584
1827
 
1828
+ assertBleActive();
1585
1829
  // At this point, peripheral is guaranteed to be defined
1586
1830
  if (!peripheral) {
1587
1831
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
@@ -1604,6 +1848,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1604
1848
  // Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
1605
1849
  // and MTU listeners may still hold the webContents of a soft-restarted
1606
1850
  // renderer, and the reuse fast path below returns before any other setup.
1851
+ assertBleActive();
1607
1852
  setupDisconnectListener(peripheral, deviceId, webContents);
1608
1853
 
1609
1854
  // Check if we already have characteristics for this device
@@ -1654,6 +1899,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1654
1899
  deviceId,
1655
1900
  webContents
1656
1901
  );
1902
+ assertBleActive();
1657
1903
  deviceCharacteristics.set(deviceId, characteristics);
1658
1904
  logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1659
1905
  } catch (setupError) {
@@ -1665,72 +1911,43 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1665
1911
  return;
1666
1912
  }
1667
1913
 
1668
- return new Promise((resolve, reject) => {
1669
- let connectionTimedOut = false;
1670
- const timeout = setTimeout(() => {
1671
- connectionTimedOut = true;
1672
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
1673
- }, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
1674
-
1675
- // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1676
- const connectedPeripheral = peripheral as Peripheral;
1677
- connectedPeripheral.connect(async (error: Error | undefined) => {
1678
- clearTimeout(timeout);
1914
+ const connectedPeripheral = peripheral;
1915
+ await connectPeripheralWithCancellation(connectedPeripheral, deviceId);
1916
+ logger?.info('[NobleBLE] Connected to device:', deviceId);
1679
1917
 
1680
- // Noble may invoke the callback after the SDK timed out and released the request.
1681
- // Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
1682
- if (connectionTimedOut) {
1683
- if (!error) {
1684
- try {
1685
- connectedPeripheral.disconnect(() => undefined);
1686
- } catch {
1687
- // Best-effort cleanup only.
1688
- }
1689
- }
1690
- return;
1691
- }
1692
-
1693
- if (error) {
1694
- logger?.error('[NobleBLE] Connection failed:', error);
1695
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1696
- return;
1697
- }
1698
-
1699
- logger?.info('[NobleBLE] Connected to device:', deviceId);
1700
- connectedDevices.set(deviceId, connectedPeripheral);
1701
-
1702
- // Setup connection and discover services
1703
- try {
1704
- const characteristics = await setupConnectionAndDiscoverServices(
1705
- connectedPeripheral,
1706
- deviceId,
1707
- webContents
1708
- );
1709
- deviceCharacteristics.set(deviceId, characteristics);
1710
- logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1711
- resolve();
1712
- } catch (setupError) {
1713
- logger?.error('[NobleBLE] Connection setup failed:', setupError);
1714
- // Never reject from inside a raw disconnect callback: noble only fires
1715
- // it on a real 'disconnect' event, so a peripheral that is already
1716
- // down leaves this promise — and the renderer acquire awaiting it —
1717
- // pending forever. disconnectDevice always settles (it no-ops on an
1718
- // unknown peripheral and caps the confirm wait).
1719
- disconnectDevice(deviceId)
1720
- .catch(() => undefined)
1721
- .then(() => reject(setupError));
1722
- }
1723
- });
1724
- });
1918
+ // Setup connection and discover services
1919
+ try {
1920
+ const characteristics = await setupConnectionAndDiscoverServices(
1921
+ connectedPeripheral,
1922
+ deviceId,
1923
+ webContents
1924
+ );
1925
+ assertBleActive();
1926
+ deviceCharacteristics.set(deviceId, characteristics);
1927
+ logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1928
+ } catch (setupError) {
1929
+ logger?.error('[NobleBLE] Connection setup failed:', setupError);
1930
+ await disconnectDevice(deviceId).catch(() => undefined);
1931
+ throw setupError;
1932
+ }
1725
1933
  }
1726
1934
 
1727
1935
  // Disconnect device
1728
1936
  async function disconnectDevice(deviceId: string): Promise<void> {
1729
- const peripheral = connectedDevices.get(deviceId);
1937
+ if (nativeReleased) return;
1938
+ const pendingConnection = connectingDevices.get(deviceId);
1939
+ const peripheral = connectedDevices.get(deviceId) ?? pendingConnection?.peripheral;
1730
1940
  if (!peripheral) {
1731
1941
  return;
1732
1942
  }
1733
1943
 
1944
+ pendingConnection?.cancel(
1945
+ ERRORS.TypedError(
1946
+ HardwareErrorCode.BleDeviceDisconnected,
1947
+ `Device ${deviceId} connection cancelled`
1948
+ )
1949
+ );
1950
+
1734
1951
  const disconnectEntry = deviceDisconnectListeners.get(deviceId);
1735
1952
  if (disconnectEntry) {
1736
1953
  disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
@@ -1758,6 +1975,7 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1758
1975
 
1759
1976
  // Unsubscribe from notifications
1760
1977
  async function unsubscribeNotifications(deviceId: string): Promise<void> {
1978
+ if (nativeReleased) return;
1761
1979
  const peripheral = connectedDevices.get(deviceId);
1762
1980
  const characteristics = deviceCharacteristics.get(deviceId);
1763
1981
 
@@ -1785,7 +2003,7 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1785
2003
  subscribedDevices.delete(deviceId);
1786
2004
  } finally {
1787
2005
  // 🔒 CRITICAL: Always clear operation state (even on error)
1788
- subscriptionOperations.set(deviceId, 'idle');
2006
+ if (!disposing) subscriptionOperations.set(deviceId, 'idle');
1789
2007
  }
1790
2008
  }
1791
2009
 
@@ -1874,11 +2092,13 @@ async function subscribeNotifications(
1874
2092
  timeoutMs: BLE_CLEANUP_TIMEOUT,
1875
2093
  timeoutBehavior: 'resolve',
1876
2094
  });
2095
+ assertBleActive();
1877
2096
  await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1878
- timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
2097
+ timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
1879
2098
  timeoutBehavior: 'reject',
1880
2099
  });
1881
2100
 
2101
+ assertBleActive();
1882
2102
  notifyCharacteristic.on('data', (data: Buffer) => {
1883
2103
  // Windows BLE pairing detection: receiving any data means device is paired
1884
2104
  if (!pairedDevices.has(deviceId)) {
@@ -1893,19 +2113,27 @@ async function subscribeNotifications(
1893
2113
  const subscribeStartedAt = Date.now();
1894
2114
  try {
1895
2115
  await rebuildAppSubscription(deviceId, notifyCharacteristic);
2116
+ assertBleActive();
1896
2117
  subscribedDevices.set(deviceId, true);
1897
2118
  logger?.info('[NobleBLE] Notification subscription active', {
1898
2119
  deviceId,
1899
2120
  ms: Date.now() - subscribeStartedAt,
1900
2121
  });
2122
+ } catch (error) {
2123
+ throw createNobleBleConnectionError(
2124
+ error as NobleBleNativeError,
2125
+ 'Notification subscription failed: '
2126
+ );
1901
2127
  } finally {
1902
2128
  // 🔒 CRITICAL: Always clear operation state (even on error)
1903
- subscriptionOperations.set(deviceId, 'idle');
2129
+ if (!disposing) subscriptionOperations.set(deviceId, 'idle');
1904
2130
  }
1905
2131
  }
1906
2132
 
1907
2133
  // Setup IPC handlers
1908
2134
  export function setupNobleBleHandlers(webContents: WebContents): void {
2135
+ if (disposing) return;
2136
+ let windowDestroyed = false;
1909
2137
  try {
1910
2138
  // @ts-ignore – electron-log is only available at runtime
1911
2139
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
@@ -1915,10 +2143,29 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1915
2143
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1916
2144
  const { ipcMain } = require('electron') as { ipcMain: IpcMain };
1917
2145
 
2146
+ const channels = new Set<string>();
2147
+ removeIpcHandlers = () => channels.forEach(channel => ipcMain.removeHandler(channel));
2148
+
1918
2149
  // Electron throws on duplicate channels and setup re-runs on soft restart.
1919
2150
  const handle: IpcMain['handle'] = (channel, listener) => {
2151
+ channels.add(channel);
1920
2152
  ipcMain.removeHandler(channel);
1921
- ipcMain.handle(channel, listener);
2153
+ ipcMain.handle(channel, async (...args) => {
2154
+ try {
2155
+ // A replacement renderer must not race the old renderer's shared-state cleanup.
2156
+ if (windowCleanup) await windowCleanup;
2157
+ assertBleActive();
2158
+ if (windowDestroyed) {
2159
+ throw ERRORS.TypedError(
2160
+ HardwareErrorCode.BleDeviceDisconnected,
2161
+ 'BLE window destroyed'
2162
+ );
2163
+ }
2164
+ return await Promise.resolve(listener(...args));
2165
+ } catch (error) {
2166
+ return createNobleBleIpcErrorResponse(error);
2167
+ }
2168
+ });
1922
2169
  };
1923
2170
 
1924
2171
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
@@ -1926,7 +2173,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1926
2173
  // Handle enumerate request
1927
2174
  handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1928
2175
  try {
1929
- const devices = await enumerateDevices();
2176
+ const devices = await enumerateDevices(() => windowDestroyed);
1930
2177
  safeLog(logger, 'debug', 'Enumeration completed', {
1931
2178
  count: devices.length,
1932
2179
  devices: devices.map(device => ({ id: device.id, name: device.name })),
@@ -2069,16 +2316,27 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
2069
2316
  }
2070
2317
  });
2071
2318
 
2072
- // Cleanup on app quit
2319
+ // Window cleanup also runs during renderer soft restart; native release belongs to app quit.
2073
2320
  webContents.on('destroyed', () => {
2321
+ windowDestroyed = true;
2322
+ if (disposing) return;
2074
2323
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
2075
- (async () => {
2324
+ const previousCleanup = windowCleanup;
2325
+ // Cancel before yielding so old timers cannot stop a replacement renderer's scan.
2326
+ const enumerations = Array.from(pendingEnumerations, cancel => cancel());
2327
+ windowCleanup = (async () => {
2328
+ if (previousCleanup) await previousCleanup;
2329
+ await Promise.allSettled(enumerations);
2330
+ if (disposing) return;
2076
2331
  const deviceIds = Array.from(connectedDevices.keys());
2077
2332
  for (const deviceId of deviceIds) {
2333
+ if (disposing) return;
2078
2334
  await unsubscribeNotifications(deviceId).catch(() => undefined);
2335
+ if (disposing) return;
2079
2336
  await disconnectDevice(deviceId).catch(() => undefined);
2080
2337
  }
2081
2338
 
2339
+ if (disposing) return;
2082
2340
  await stopScanning().catch(() => undefined);
2083
2341
  // persistentStateListener is process-lifetime, NOT per-window: this
2084
2342
  // destroy handler also fires on a renderer soft restart, and removing
@@ -2100,3 +2358,77 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
2100
2358
  throw error;
2101
2359
  }
2102
2360
  }
2361
+
2362
+ /**
2363
+ * Terminal, bounded cleanup while the Node environment is still alive.
2364
+ * If Noble is shared with another transport, releaseNoble must queue the instance
2365
+ * and call stop() once after every transport has finished its cleanup.
2366
+ */
2367
+ export function disposeNobleBleSupport(
2368
+ releaseNoble: (instance: { stop(): void }) => void = instance => instance.stop()
2369
+ ): Promise<void> {
2370
+ if (disposePromise) return disposePromise;
2371
+ disposing = true;
2372
+ removeIpcHandlers?.();
2373
+ const instance = noble;
2374
+ const deviceIds = new Set([...connectedDevices.keys(), ...connectingDevices.keys()]);
2375
+ const pendingPeripherals = Array.from(connectingDevices.values(), pending => pending.peripheral);
2376
+ for (const cancel of pendingCancellations) cancel();
2377
+ for (const id of idleDisconnectTimers.keys()) clearIdleDisconnect(id);
2378
+ for (const pending of connectingDevices.values()) {
2379
+ pending.cancel(
2380
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
2381
+ );
2382
+ }
2383
+ const connections = Array.from(nativeConnections);
2384
+ for (const connection of connections) {
2385
+ try {
2386
+ connection.cancel();
2387
+ } catch (error) {
2388
+ logger?.warn('[NobleBLE] Native connect cancellation failed', error);
2389
+ }
2390
+ }
2391
+ let timeout: ReturnType<typeof setTimeout> | undefined;
2392
+ disposePromise = (async () => {
2393
+ try {
2394
+ await Promise.race([
2395
+ Promise.allSettled([
2396
+ windowCleanup,
2397
+ ...connections.map(connection => connection.settled),
2398
+ stopScanning(),
2399
+ ...pendingPeripherals.map(peripheral =>
2400
+ runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
2401
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
2402
+ timeoutBehavior: 'resolve',
2403
+ })
2404
+ ),
2405
+ ...Array.from(deviceIds, async id => {
2406
+ await unsubscribeNotifications(id).catch(() => undefined);
2407
+ await disconnectDevice(id).catch(() => undefined);
2408
+ }),
2409
+ ]),
2410
+ new Promise<void>(resolve => {
2411
+ timeout = setTimeout(() => {
2412
+ logger?.warn('[NobleBLE] Process dispose timed out; releasing native manager');
2413
+ resolve();
2414
+ }, 3500);
2415
+ }),
2416
+ ]);
2417
+ } finally {
2418
+ clearTimeout(timeout);
2419
+ // Bounded cleanup may finish before a broken native callback; it must stay inert afterwards.
2420
+ nativeReleased = true;
2421
+ cleanupNobleListeners();
2422
+ if (instance && persistentStateListener) {
2423
+ instance.removeListener('stateChange', persistentStateListener);
2424
+ persistentStateListener = null;
2425
+ }
2426
+ for (const id of deviceIds) cleanupDevice(id);
2427
+ discoveredDevices.clear();
2428
+ directConnectCooldownUntil.clear();
2429
+ if (instance) releaseNoble(instance);
2430
+ logger?.info('[NobleBLE] Process dispose completed');
2431
+ }
2432
+ })();
2433
+ return disposePromise;
2434
+ }