@onekeyfe/hd-transport-electron 1.2.2-alpha.12 → 1.2.2-alpha.122

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,12 +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,
18
- isBleStaleBondErrorText,
19
+ isBleStaleBondHardwareError,
19
20
  isOnekeyBluetoothDevice,
20
21
  isPro2FamilyBleName,
21
22
  matchesKnownBleUuid,
@@ -27,32 +28,101 @@ import { resolveBlePacketCapacity, resolveNobleAttMtu } from './ble-packet-capac
27
28
  import { safeLog } from './types/noble-extended';
28
29
  import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
29
30
  import {
30
- NOBLE_BLE_CONNECTION_TIMEOUT_MS,
31
+ NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
31
32
  NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
32
33
  } from './noble-ble-timeouts';
33
34
 
34
35
  import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
35
36
  import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
36
- import type { NobleBleWriteOptions } from './types/desktop-api';
37
+ import type { NobleBleIpcErrorResponse, NobleBleWriteOptions } from './types/desktop-api';
37
38
  import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
38
39
 
39
40
  // Noble will be dynamically imported to avoid bundling issues
40
41
  let noble: NobleModule | null = null;
41
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
+ }
42
69
 
43
- export function createNobleBleConnectionError(error: Error, messagePrefix = '') {
70
+ type NobleBleNativeError = Error & {
71
+ nativeErrorCode?: number;
72
+ nativeErrorDomain?: string;
73
+ };
74
+
75
+ export function createNobleBleConnectionError(error: NobleBleNativeError, messagePrefix = '') {
44
76
  const errorMessage = error.message;
45
- const normalizedErrorMessage = errorMessage.toLowerCase();
46
- let errorCode = HardwareErrorCode.BleConnectedError;
47
- if (isBleStaleBondErrorText(errorMessage)) {
48
- errorCode =
49
- normalizedErrorMessage.includes('peer removed pairing information') ||
50
- normalizedErrorMessage.includes('cberrordomain:14')
51
- ? HardwareErrorCode.BlePeerRemovedPairingInformation
52
- : HardwareErrorCode.BleDeviceBondError;
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
+ );
53
89
  }
54
90
 
55
- return ERRORS.TypedError(errorCode, `${messagePrefix}${errorMessage}`);
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
+ };
56
126
  }
57
127
 
58
128
  // Bluetooth state management
@@ -73,6 +143,102 @@ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null
73
143
  // Device cache and connection state
74
144
  const discoveredDevices = new Map<string, Peripheral>();
75
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
+ }
76
242
  const pairedDevices = new Set<string>(); // Windows BLE device pairing status tracking
77
243
  const deviceCharacteristics = new Map<string, CharacteristicPair>();
78
244
  const notificationCallbacks = new Map<string, (data: string) => void>();
@@ -277,6 +443,7 @@ function updateBluetoothState(state: string): void {
277
443
 
278
444
  // Initialize Noble
279
445
  async function initializeNoble(): Promise<void> {
446
+ assertBleActive();
280
447
  if (noble) return;
281
448
 
282
449
  try {
@@ -302,12 +469,14 @@ async function initializeNoble(): Promise<void> {
302
469
  }
303
470
 
304
471
  const timeout = setTimeout(() => {
472
+ cleanup();
305
473
  reject(
306
474
  ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
307
475
  );
308
476
  }, BLUETOOTH_INIT_TIMEOUT);
309
477
 
310
478
  const cleanup = () => {
479
+ pendingCancellations.delete(cancel);
311
480
  clearTimeout(timeout);
312
481
  if (noble) {
313
482
  noble.removeListener('stateChange', onStateChange);
@@ -332,9 +501,17 @@ async function initializeNoble(): Promise<void> {
332
501
  }
333
502
  };
334
503
 
504
+ const cancel = () => {
505
+ cleanup();
506
+ reject(
507
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
508
+ );
509
+ };
510
+ pendingCancellations.add(cancel);
335
511
  noble.on('stateChange', onStateChange);
336
512
  });
337
513
 
514
+ assertBleActive();
338
515
  // Set up device discovery
339
516
  if (!persistentDiscoverListener) {
340
517
  persistentDiscoverListener = (peripheral: Peripheral) => {
@@ -419,6 +596,7 @@ function armIdleDisconnect(
419
596
  reason: 'idle' | 'busy-backstop' = 'idle'
420
597
  ): void {
421
598
  clearIdleDisconnect(deviceId);
599
+ if (disposing) return;
422
600
  idleDisconnectTimers.set(
423
601
  deviceId,
424
602
  setTimeout(() => {
@@ -501,6 +679,14 @@ function cleanupDevice(
501
679
 
502
680
  // 1. Clean up connection state
503
681
  if (cleanupConnection) {
682
+ connectingDevices
683
+ .get(deviceId)
684
+ ?.cancel(
685
+ ERRORS.TypedError(
686
+ HardwareErrorCode.BleDeviceDisconnected,
687
+ `Device ${deviceId} disconnected while connecting`
688
+ )
689
+ );
504
690
  const disconnectEntry = deviceDisconnectListeners.get(deviceId);
505
691
  if (disconnectEntry) {
506
692
  disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
@@ -610,6 +796,7 @@ async function writeCharacteristicWithoutResponse(
610
796
  writeCharacteristic: Characteristic,
611
797
  buffer: Buffer
612
798
  ): Promise<void> {
799
+ assertBleActive();
613
800
  return new Promise((resolve, reject) => {
614
801
  writeCharacteristic.write(buffer, true, (error?: Error) => {
615
802
  if (error) {
@@ -894,6 +1081,7 @@ async function performTargetedScan(
894
1081
  targetDeviceId: string,
895
1082
  timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
896
1083
  ): Promise<Peripheral | null> {
1084
+ assertBleActive();
897
1085
  if (!noble) {
898
1086
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
899
1087
  }
@@ -909,6 +1097,7 @@ async function performTargetedScan(
909
1097
  const finish = async (peripheral: Peripheral | null, error?: Error) => {
910
1098
  if (settled) return;
911
1099
  settled = true;
1100
+ pendingCancellations.delete(cancel);
912
1101
  if (timeoutId) clearTimeout(timeoutId);
913
1102
  nobleInstance.removeListener('discover', onDiscover);
914
1103
  await waitForNobleScanStop(nobleInstance);
@@ -918,6 +1107,7 @@ async function performTargetedScan(
918
1107
  reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
919
1108
  return;
920
1109
  }
1110
+ assertBleActive();
921
1111
  if (peripheral) {
922
1112
  discoveredDevices.set(peripheral.id, peripheral);
923
1113
  }
@@ -940,6 +1130,11 @@ async function performTargetedScan(
940
1130
  finish(null).catch(reject);
941
1131
  }, timeoutMs);
942
1132
 
1133
+ const cancel = () => {
1134
+ finish(null, new Error('Noble BLE is shutting down')).catch(reject);
1135
+ };
1136
+ pendingCancellations.add(cancel);
1137
+
943
1138
  // Add local listener for this scan
944
1139
  nobleInstance.on('discover', onDiscover);
945
1140
 
@@ -956,7 +1151,7 @@ async function performTargetedScan(
956
1151
  }
957
1152
 
958
1153
  // Enumerate devices
959
- async function enumerateDevices(): Promise<DeviceInfo[]> {
1154
+ async function enumerateDevices(isWindowDestroyed: () => boolean): Promise<DeviceInfo[]> {
960
1155
  if (!noble) {
961
1156
  await initializeNoble();
962
1157
  }
@@ -965,13 +1160,14 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
965
1160
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
966
1161
  }
967
1162
 
1163
+ assertBleActive();
1164
+ if (isWindowDestroyed()) return [];
968
1165
  // Capture noble reference for use in closures (TypeScript narrowing)
969
1166
  const nobleInstance = noble;
970
1167
 
971
1168
  logger?.info('[NobleBLE] Starting device enumeration');
972
1169
 
973
- // Clear previous discoveries
974
- discoveredDevices.clear();
1170
+ // Keep prior discoveries when a polling round misses an advertisement.
975
1171
 
976
1172
  // Ensure discover listener is properly set up before scanning
977
1173
  // This is crucial to fix the issue where devices are not found after web-usb failures
@@ -979,15 +1175,24 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
979
1175
 
980
1176
  return new Promise((resolve, reject) => {
981
1177
  const devices: DeviceInfo[] = [];
1178
+ let settled = false;
982
1179
  let intervalId: ReturnType<typeof setInterval> | undefined;
1180
+ let cleanupPromise: Promise<void> | undefined;
983
1181
 
984
1182
  // Cleanup function: clears timers and waits until Noble confirms scanning
985
1183
  // has stopped. Resolving enumerate before this callback creates a race with
986
1184
  // an immediately-following connection attempt.
987
- const cleanup = async () => {
988
- clearTimeout(timeoutId);
989
- if (intervalId) clearInterval(intervalId);
990
- 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;
991
1196
  };
992
1197
 
993
1198
  // Collect discovered devices into the devices array
@@ -1008,6 +1213,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1008
1213
 
1009
1214
  // Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
1010
1215
  const timeoutId = setTimeout(async () => {
1216
+ if (settled) return;
1011
1217
  // Final collection before resolving — catches devices discovered near the deadline
1012
1218
  checkDevices();
1013
1219
  await cleanup();
@@ -1015,11 +1221,16 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1015
1221
  resolve(devices);
1016
1222
  }, DEVICE_SCAN_TIMEOUT);
1017
1223
 
1224
+ const cancel = () => cleanup().then(() => resolve([]), reject);
1225
+ pendingCancellations.add(cancel);
1226
+ pendingEnumerations.add(cancel);
1227
+
1018
1228
  // Start scanning without a service UUID filter so Pro2 advertisements with
1019
1229
  // short vendor UUIDs can be found. Repeated advertisements are required when
1020
1230
  // the local name arrives in a later scan response; discoveredDevices handles deduplication.
1021
1231
  logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
1022
1232
  nobleInstance.startScanning([], true, async (error?: Error) => {
1233
+ if (settled) return;
1023
1234
  if (error) {
1024
1235
  await cleanup();
1025
1236
  logger?.error('[NobleBLE] Failed to start scanning:', error);
@@ -1037,7 +1248,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
1037
1248
 
1038
1249
  // Stop scanning
1039
1250
  async function stopScanning(): Promise<void> {
1040
- if (!noble) return;
1251
+ if (!noble || nativeReleased) return;
1041
1252
  const nobleInstance = noble;
1042
1253
  await waitForNobleScanStop(nobleInstance);
1043
1254
  logger?.info('[NobleBLE] Scanning stopped');
@@ -1110,13 +1321,17 @@ function getDevice(deviceId: string): DeviceInfo | null {
1110
1321
  async function discoverServicesAndCharacteristics(
1111
1322
  peripheral: Peripheral
1112
1323
  ): Promise<CharacteristicPair> {
1324
+ assertBleActive();
1113
1325
  // Cleanup resources - will be set up and cleaned in try/finally
1114
1326
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
1115
1327
  let onDisconnect: (() => void) | undefined;
1116
1328
 
1117
1329
  const cleanup = () => {
1118
1330
  if (timeoutId) clearTimeout(timeoutId);
1119
- if (onDisconnect) peripheral.removeListener('disconnect', onDisconnect);
1331
+ if (onDisconnect) {
1332
+ peripheral.removeListener('disconnect', onDisconnect);
1333
+ pendingCancellations.delete(onDisconnect);
1334
+ }
1120
1335
  };
1121
1336
 
1122
1337
  // Racing promises for timeout and disconnect
@@ -1137,6 +1352,7 @@ async function discoverServicesAndCharacteristics(
1137
1352
  )
1138
1353
  );
1139
1354
  };
1355
+ pendingCancellations.add(onDisconnect);
1140
1356
  peripheral.once('disconnect', onDisconnect);
1141
1357
  });
1142
1358
 
@@ -1162,6 +1378,7 @@ async function discoverServicesAndCharacteristics(
1162
1378
  });
1163
1379
  });
1164
1380
 
1381
+ assertBleActive();
1165
1382
  if (!services || services.length === 0) {
1166
1383
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
1167
1384
  }
@@ -1268,12 +1485,8 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1268
1485
  }
1269
1486
 
1270
1487
  // Step 3: Re-establish connection
1271
- await runBleCallbackOperation(callback => peripheral.connect(callback), {
1272
- timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1273
- timeoutBehavior: 'reject',
1274
- });
1488
+ await connectPeripheralWithCancellation(peripheral, deviceId);
1275
1489
  logger?.info('[NobleBLE] Force reconnect successful');
1276
- connectedDevices.set(deviceId, peripheral);
1277
1490
 
1278
1491
  // Wait for connection to stabilize
1279
1492
  await wait(500);
@@ -1309,16 +1522,11 @@ async function freshScanAndDiscover(
1309
1522
  discoveredDevices.set(deviceId, freshPeripheral);
1310
1523
 
1311
1524
  // Connect to fresh peripheral
1312
- await new Promise<void>((resolve, reject) => {
1313
- freshPeripheral.connect((error: Error | undefined) => {
1314
- if (error) {
1315
- reject(createNobleBleConnectionError(error, 'Fresh peripheral connection failed: '));
1316
- } else {
1317
- connectedDevices.set(deviceId, freshPeripheral);
1318
- resolve();
1319
- }
1320
- });
1321
- });
1525
+ await connectPeripheralWithCancellation(
1526
+ freshPeripheral,
1527
+ deviceId,
1528
+ 'Fresh peripheral connection failed: '
1529
+ );
1322
1530
 
1323
1531
  // Setup disconnect listener for fresh peripheral
1324
1532
  setupDisconnectListener(freshPeripheral, deviceId, webContents);
@@ -1390,6 +1598,7 @@ async function discoverServicesAndCharacteristicsWithRetry(
1390
1598
  minTimeout: 500,
1391
1599
  maxTimeout: 3000,
1392
1600
  onFailedAttempt: error => {
1601
+ assertBleActive();
1393
1602
  // This runs after each failed attempt
1394
1603
  logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
1395
1604
  message: error.message,
@@ -1425,10 +1634,17 @@ async function setupConnectionAndDiscoverServices(
1425
1634
  try {
1426
1635
  await forceReconnectPeripheral(peripheral, deviceId);
1427
1636
  } catch (resetError) {
1637
+ if (
1638
+ isBleStaleBondHardwareError(resetError) ||
1639
+ (resetError as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected
1640
+ ) {
1641
+ throw resetError;
1642
+ }
1428
1643
  // A failed reset must not abort the attempt: discovery on the existing
1429
1644
  // link, then the fresh-scan fallback, still have a chance to recover.
1430
1645
  logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1431
1646
  }
1647
+ assertBleActive();
1432
1648
  setupDisconnectListener(peripheral, deviceId, webContents);
1433
1649
 
1434
1650
  try {
@@ -1471,6 +1687,7 @@ const directConnectCooldownUntil = new Map<string, number>();
1471
1687
  * Ported from the Trezor connector, where it is field-proven.
1472
1688
  */
1473
1689
  async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
1690
+ assertBleActive();
1474
1691
  const nobleInstance = noble as
1475
1692
  | (typeof noble & {
1476
1693
  connectAsync?: (id: string) => Promise<Peripheral | undefined>;
@@ -1481,34 +1698,42 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1481
1698
  const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
1482
1699
  if (Date.now() < cooldownUntil) return undefined;
1483
1700
 
1701
+ let timer: ReturnType<typeof setTimeout> | undefined;
1702
+ let cancel: (() => void) | undefined;
1703
+ let abandoned = false;
1484
1704
  try {
1485
1705
  // The late-orphan guard must attach to THIS pending connect.
1486
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
+ );
1487
1721
  const raced = await Promise.race([
1488
1722
  directPromise,
1489
1723
  new Promise<'timeout'>(resolve => {
1490
- 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);
1491
1730
  }),
1492
1731
  ]);
1493
- if (raced === 'timeout') {
1732
+ if (raced === 'timeout' || disposing) {
1494
1733
  directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1495
1734
  logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1496
1735
  deviceId,
1497
1736
  });
1498
- // Promise.race times out the caller only; a late success orphans the link.
1499
- directPromise
1500
- .then(late => {
1501
- const latePeripheral = late ?? discoveredDevices.get(deviceId);
1502
- if (
1503
- latePeripheral &&
1504
- latePeripheral.state === 'connected' &&
1505
- !connectedDevices.has(deviceId)
1506
- ) {
1507
- latePeripheral.removeAllListeners('disconnect');
1508
- latePeripheral.disconnect(() => undefined);
1509
- }
1510
- })
1511
- .catch(() => undefined);
1512
1737
  return undefined;
1513
1738
  }
1514
1739
  // Backends emit `discover` as a side effect, so the cache may hold it.
@@ -1523,6 +1748,9 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1523
1748
  error: String(error),
1524
1749
  });
1525
1750
  return undefined;
1751
+ } finally {
1752
+ clearTimeout(timer);
1753
+ if (cancel) pendingCancellations.delete(cancel);
1526
1754
  }
1527
1755
  }
1528
1756
 
@@ -1537,8 +1765,12 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1537
1765
  totalConnected: connectedDevices.size,
1538
1766
  });
1539
1767
 
1540
- // enumerate clears the discovery map; a kept-alive link outlives it.
1541
- 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
+ }
1542
1774
 
1543
1775
  if (!peripheral) {
1544
1776
  // Initialize Noble if not already done
@@ -1593,6 +1825,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1593
1825
  }
1594
1826
  }
1595
1827
 
1828
+ assertBleActive();
1596
1829
  // At this point, peripheral is guaranteed to be defined
1597
1830
  if (!peripheral) {
1598
1831
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
@@ -1615,6 +1848,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1615
1848
  // Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
1616
1849
  // and MTU listeners may still hold the webContents of a soft-restarted
1617
1850
  // renderer, and the reuse fast path below returns before any other setup.
1851
+ assertBleActive();
1618
1852
  setupDisconnectListener(peripheral, deviceId, webContents);
1619
1853
 
1620
1854
  // Check if we already have characteristics for this device
@@ -1665,6 +1899,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1665
1899
  deviceId,
1666
1900
  webContents
1667
1901
  );
1902
+ assertBleActive();
1668
1903
  deviceCharacteristics.set(deviceId, characteristics);
1669
1904
  logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1670
1905
  } catch (setupError) {
@@ -1676,72 +1911,43 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1676
1911
  return;
1677
1912
  }
1678
1913
 
1679
- return new Promise((resolve, reject) => {
1680
- let connectionTimedOut = false;
1681
- const timeout = setTimeout(() => {
1682
- connectionTimedOut = true;
1683
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
1684
- }, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
1685
-
1686
- // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1687
- const connectedPeripheral = peripheral as Peripheral;
1688
- connectedPeripheral.connect(async (error: Error | undefined) => {
1689
- clearTimeout(timeout);
1690
-
1691
- // Noble may invoke the callback after the SDK timed out and released the request.
1692
- // Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
1693
- if (connectionTimedOut) {
1694
- if (!error) {
1695
- try {
1696
- connectedPeripheral.disconnect(() => undefined);
1697
- } catch {
1698
- // Best-effort cleanup only.
1699
- }
1700
- }
1701
- return;
1702
- }
1914
+ const connectedPeripheral = peripheral;
1915
+ await connectPeripheralWithCancellation(connectedPeripheral, deviceId);
1916
+ logger?.info('[NobleBLE] Connected to device:', deviceId);
1703
1917
 
1704
- if (error) {
1705
- logger?.error('[NobleBLE] Connection failed:', error);
1706
- reject(createNobleBleConnectionError(error));
1707
- return;
1708
- }
1709
-
1710
- logger?.info('[NobleBLE] Connected to device:', deviceId);
1711
- connectedDevices.set(deviceId, connectedPeripheral);
1712
-
1713
- // Setup connection and discover services
1714
- try {
1715
- const characteristics = await setupConnectionAndDiscoverServices(
1716
- connectedPeripheral,
1717
- deviceId,
1718
- webContents
1719
- );
1720
- deviceCharacteristics.set(deviceId, characteristics);
1721
- logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1722
- resolve();
1723
- } catch (setupError) {
1724
- logger?.error('[NobleBLE] Connection setup failed:', setupError);
1725
- // Never reject from inside a raw disconnect callback: noble only fires
1726
- // it on a real 'disconnect' event, so a peripheral that is already
1727
- // down leaves this promise — and the renderer acquire awaiting it —
1728
- // pending forever. disconnectDevice always settles (it no-ops on an
1729
- // unknown peripheral and caps the confirm wait).
1730
- disconnectDevice(deviceId)
1731
- .catch(() => undefined)
1732
- .then(() => reject(setupError));
1733
- }
1734
- });
1735
- });
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
+ }
1736
1933
  }
1737
1934
 
1738
1935
  // Disconnect device
1739
1936
  async function disconnectDevice(deviceId: string): Promise<void> {
1740
- const peripheral = connectedDevices.get(deviceId);
1937
+ if (nativeReleased) return;
1938
+ const pendingConnection = connectingDevices.get(deviceId);
1939
+ const peripheral = connectedDevices.get(deviceId) ?? pendingConnection?.peripheral;
1741
1940
  if (!peripheral) {
1742
1941
  return;
1743
1942
  }
1744
1943
 
1944
+ pendingConnection?.cancel(
1945
+ ERRORS.TypedError(
1946
+ HardwareErrorCode.BleDeviceDisconnected,
1947
+ `Device ${deviceId} connection cancelled`
1948
+ )
1949
+ );
1950
+
1745
1951
  const disconnectEntry = deviceDisconnectListeners.get(deviceId);
1746
1952
  if (disconnectEntry) {
1747
1953
  disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
@@ -1769,6 +1975,7 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1769
1975
 
1770
1976
  // Unsubscribe from notifications
1771
1977
  async function unsubscribeNotifications(deviceId: string): Promise<void> {
1978
+ if (nativeReleased) return;
1772
1979
  const peripheral = connectedDevices.get(deviceId);
1773
1980
  const characteristics = deviceCharacteristics.get(deviceId);
1774
1981
 
@@ -1796,7 +2003,7 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1796
2003
  subscribedDevices.delete(deviceId);
1797
2004
  } finally {
1798
2005
  // 🔒 CRITICAL: Always clear operation state (even on error)
1799
- subscriptionOperations.set(deviceId, 'idle');
2006
+ if (!disposing) subscriptionOperations.set(deviceId, 'idle');
1800
2007
  }
1801
2008
  }
1802
2009
 
@@ -1885,11 +2092,13 @@ async function subscribeNotifications(
1885
2092
  timeoutMs: BLE_CLEANUP_TIMEOUT,
1886
2093
  timeoutBehavior: 'resolve',
1887
2094
  });
2095
+ assertBleActive();
1888
2096
  await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1889
- timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
2097
+ timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
1890
2098
  timeoutBehavior: 'reject',
1891
2099
  });
1892
2100
 
2101
+ assertBleActive();
1893
2102
  notifyCharacteristic.on('data', (data: Buffer) => {
1894
2103
  // Windows BLE pairing detection: receiving any data means device is paired
1895
2104
  if (!pairedDevices.has(deviceId)) {
@@ -1904,19 +2113,27 @@ async function subscribeNotifications(
1904
2113
  const subscribeStartedAt = Date.now();
1905
2114
  try {
1906
2115
  await rebuildAppSubscription(deviceId, notifyCharacteristic);
2116
+ assertBleActive();
1907
2117
  subscribedDevices.set(deviceId, true);
1908
2118
  logger?.info('[NobleBLE] Notification subscription active', {
1909
2119
  deviceId,
1910
2120
  ms: Date.now() - subscribeStartedAt,
1911
2121
  });
2122
+ } catch (error) {
2123
+ throw createNobleBleConnectionError(
2124
+ error as NobleBleNativeError,
2125
+ 'Notification subscription failed: '
2126
+ );
1912
2127
  } finally {
1913
2128
  // 🔒 CRITICAL: Always clear operation state (even on error)
1914
- subscriptionOperations.set(deviceId, 'idle');
2129
+ if (!disposing) subscriptionOperations.set(deviceId, 'idle');
1915
2130
  }
1916
2131
  }
1917
2132
 
1918
2133
  // Setup IPC handlers
1919
2134
  export function setupNobleBleHandlers(webContents: WebContents): void {
2135
+ if (disposing) return;
2136
+ let windowDestroyed = false;
1920
2137
  try {
1921
2138
  // @ts-ignore – electron-log is only available at runtime
1922
2139
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
@@ -1926,10 +2143,29 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1926
2143
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1927
2144
  const { ipcMain } = require('electron') as { ipcMain: IpcMain };
1928
2145
 
2146
+ const channels = new Set<string>();
2147
+ removeIpcHandlers = () => channels.forEach(channel => ipcMain.removeHandler(channel));
2148
+
1929
2149
  // Electron throws on duplicate channels and setup re-runs on soft restart.
1930
2150
  const handle: IpcMain['handle'] = (channel, listener) => {
2151
+ channels.add(channel);
1931
2152
  ipcMain.removeHandler(channel);
1932
- 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
+ });
1933
2169
  };
1934
2170
 
1935
2171
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
@@ -1937,7 +2173,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1937
2173
  // Handle enumerate request
1938
2174
  handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1939
2175
  try {
1940
- const devices = await enumerateDevices();
2176
+ const devices = await enumerateDevices(() => windowDestroyed);
1941
2177
  safeLog(logger, 'debug', 'Enumeration completed', {
1942
2178
  count: devices.length,
1943
2179
  devices: devices.map(device => ({ id: device.id, name: device.name })),
@@ -2080,16 +2316,27 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
2080
2316
  }
2081
2317
  });
2082
2318
 
2083
- // Cleanup on app quit
2319
+ // Window cleanup also runs during renderer soft restart; native release belongs to app quit.
2084
2320
  webContents.on('destroyed', () => {
2321
+ windowDestroyed = true;
2322
+ if (disposing) return;
2085
2323
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
2086
- (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;
2087
2331
  const deviceIds = Array.from(connectedDevices.keys());
2088
2332
  for (const deviceId of deviceIds) {
2333
+ if (disposing) return;
2089
2334
  await unsubscribeNotifications(deviceId).catch(() => undefined);
2335
+ if (disposing) return;
2090
2336
  await disconnectDevice(deviceId).catch(() => undefined);
2091
2337
  }
2092
2338
 
2339
+ if (disposing) return;
2093
2340
  await stopScanning().catch(() => undefined);
2094
2341
  // persistentStateListener is process-lifetime, NOT per-window: this
2095
2342
  // destroy handler also fires on a renderer soft restart, and removing
@@ -2111,3 +2358,77 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
2111
2358
  throw error;
2112
2359
  }
2113
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
+ }