@onekeyfe/hd-transport-electron 1.2.2-alpha.100 → 1.2.2-alpha.102

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,33 +10,104 @@ 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,
21
+ isPro2FamilyBleName,
19
22
  matchesKnownBleUuid,
20
23
  wait,
21
24
  } from '@onekeyfe/hd-shared';
22
25
  import pRetry from 'p-retry';
23
26
 
24
- import { resolveBlePacketCapacity } from './ble-packet-capacity';
27
+ import { resolveBlePacketCapacity, resolveNobleAttMtu } from './ble-packet-capacity';
25
28
  import { safeLog } from './types/noble-extended';
26
29
  import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
27
30
  import {
28
- NOBLE_BLE_CONNECTION_TIMEOUT_MS,
31
+ NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
29
32
  NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
30
33
  } from './noble-ble-timeouts';
31
34
 
32
35
  import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
33
36
  import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
34
- import type { NobleBleWriteOptions } from './types/desktop-api';
37
+ import type { NobleBleIpcErrorResponse, NobleBleWriteOptions } from './types/desktop-api';
35
38
  import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
36
39
 
37
40
  // Noble will be dynamically imported to avoid bundling issues
38
41
  let noble: NobleModule | null = null;
39
42
  let logger: Logger | null = null;
43
+ let disposing = false;
44
+ let disposePromise: Promise<void> | undefined;
45
+ let removeIpcHandlers: (() => void) | undefined;
46
+ const pendingCancellations = new Set<() => void>();
47
+
48
+ function assertBleActive(): void {
49
+ if (disposing) {
50
+ throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down');
51
+ }
52
+ }
53
+
54
+ type NobleBleNativeError = Error & {
55
+ nativeErrorCode?: number;
56
+ nativeErrorDomain?: string;
57
+ };
58
+
59
+ export function createNobleBleConnectionError(error: NobleBleNativeError, messagePrefix = '') {
60
+ const errorMessage = error.message;
61
+ const isInvalidMacOsBond =
62
+ (error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain') ||
63
+ (error.nativeErrorCode === 15 && error.nativeErrorDomain === 'CBATTErrorDomain');
64
+ if (isInvalidMacOsBond) {
65
+ const nativeErrorMessage = `${messagePrefix}${errorMessage}`;
66
+ return ERRORS.TypedError(
67
+ HardwareErrorCode.BleBondInvalid,
68
+ `${HardwareErrorCodeMessage[HardwareErrorCode.BleBondInvalid]} (${nativeErrorMessage})`,
69
+ {
70
+ nativeErrorMessage,
71
+ }
72
+ );
73
+ }
74
+
75
+ return ERRORS.TypedError(HardwareErrorCode.BleConnectedError, `${messagePrefix}${errorMessage}`);
76
+ }
77
+
78
+ export function createNobleBleIpcErrorResponse(error: unknown): NobleBleIpcErrorResponse {
79
+ const candidate = error as {
80
+ errorCode?: unknown;
81
+ message?: unknown;
82
+ name?: unknown;
83
+ params?: unknown;
84
+ };
85
+ const errorCode =
86
+ typeof candidate?.errorCode === 'number' ? candidate.errorCode : HardwareErrorCode.UnknownError;
87
+ const message =
88
+ typeof candidate?.message === 'string' ? candidate.message : String(error ?? 'Unknown error');
89
+ const name = typeof candidate?.name === 'string' ? candidate.name : 'Error';
90
+ let params: unknown;
91
+ if (candidate?.params !== undefined) {
92
+ try {
93
+ const serializedParams = JSON.stringify(candidate.params);
94
+ params = serializedParams === undefined ? undefined : JSON.parse(serializedParams);
95
+ } catch {
96
+ params = undefined;
97
+ }
98
+ }
99
+
100
+ return {
101
+ type: 'NobleBleIpcError',
102
+ success: false,
103
+ error: {
104
+ name,
105
+ message,
106
+ errorCode,
107
+ ...(params !== undefined ? { params } : {}),
108
+ },
109
+ };
110
+ }
40
111
 
41
112
  // Bluetooth state management
42
113
  const bluetoothState: {
@@ -56,6 +127,74 @@ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null
56
127
  // Device cache and connection state
57
128
  const discoveredDevices = new Map<string, Peripheral>();
58
129
  const connectedDevices = new Map<string, Peripheral>();
130
+ type PendingDeviceConnection = {
131
+ peripheral: Peripheral;
132
+ generation: number;
133
+ cancel: (error: unknown) => void;
134
+ };
135
+ const connectingDevices = new Map<string, PendingDeviceConnection>();
136
+ let nextConnectionGeneration = 0;
137
+
138
+ function connectPeripheralWithCancellation(
139
+ peripheral: Peripheral,
140
+ deviceId: string,
141
+ messagePrefix = ''
142
+ ): Promise<void> {
143
+ assertBleActive();
144
+ return new Promise<void>((resolve, reject) => {
145
+ const generation = ++nextConnectionGeneration;
146
+ let settled = false;
147
+ const settle = (settler: () => void): boolean => {
148
+ if (settled) return false;
149
+ settled = true;
150
+ if (connectingDevices.get(deviceId)?.generation === generation) {
151
+ connectingDevices.delete(deviceId);
152
+ }
153
+ settler();
154
+ return true;
155
+ };
156
+ const pendingConnection: PendingDeviceConnection = {
157
+ peripheral,
158
+ generation,
159
+ cancel: error => {
160
+ settle(() => reject(error));
161
+ },
162
+ };
163
+ connectingDevices
164
+ .get(deviceId)
165
+ ?.cancel(
166
+ ERRORS.TypedError(
167
+ HardwareErrorCode.BleDeviceDisconnected,
168
+ `Device ${deviceId} connect attempt was superseded`
169
+ )
170
+ );
171
+ connectingDevices.set(deviceId, pendingConnection);
172
+
173
+ peripheral.connect(error => {
174
+ if (error) {
175
+ settle(() => reject(createNobleBleConnectionError(error, messagePrefix)));
176
+ return;
177
+ }
178
+
179
+ if (
180
+ !settle(() => {
181
+ connectedDevices.set(deviceId, peripheral);
182
+ resolve();
183
+ })
184
+ ) {
185
+ const activeConnection = connectingDevices.get(deviceId);
186
+ if (!activeConnection || activeConnection.peripheral !== peripheral) {
187
+ try {
188
+ peripheral.removeAllListeners('disconnect');
189
+ peripheral.disconnect(() => undefined);
190
+ } catch {
191
+ // Best-effort cleanup for a stale native callback.
192
+ }
193
+ }
194
+ }
195
+ });
196
+ });
197
+ }
59
198
  const pairedDevices = new Set<string>(); // Windows BLE device pairing status tracking
60
199
  const deviceCharacteristics = new Map<string, CharacteristicPair>();
61
200
  const notificationCallbacks = new Map<string, (data: string) => void>();
@@ -90,8 +229,18 @@ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
90
229
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
91
230
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
92
231
  const BLE_CLEANUP_TIMEOUT = 250;
232
+ // A physical teardown must actually reach the OS before the device can return
233
+ // to a clean state; 250ms routinely declared success while CoreBluetooth was
234
+ // still disconnecting, leaving no trace when the teardown never completed.
235
+ const BLE_DISCONNECT_CONFIRM_TIMEOUT_MS = 3000;
93
236
  // Renderer release is logical only; this timer physically frees the device.
94
- const BLE_IDLE_DISCONNECT_MS = 3 * 60_000;
237
+ // Keep it SHORT: the Classic 1S goes protocol-deaf when a link is dropped
238
+ // after sitting idle for minutes (field logs 2026-08-19: every deaf window
239
+ // followed a 3-minute-idle disconnect, while disconnects right after traffic
240
+ // have never produced one across 6.5.0's per-call teardown history). A hot
241
+ // disconnect ~20s after the last operation stays inside the proven-safe
242
+ // pattern and also shrinks the window in which phones cannot see the device.
243
+ const BLE_IDLE_DISCONNECT_MS = 20_000;
95
244
  // Ceiling while a call is in flight: no outstanding write, but not forever.
96
245
  const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
97
246
 
@@ -250,6 +399,7 @@ function updateBluetoothState(state: string): void {
250
399
 
251
400
  // Initialize Noble
252
401
  async function initializeNoble(): Promise<void> {
402
+ assertBleActive();
253
403
  if (noble) return;
254
404
 
255
405
  try {
@@ -257,6 +407,11 @@ async function initializeNoble(): Promise<void> {
257
407
  noble = require('@stoprocent/noble') as NobleModule;
258
408
  logger?.info('[NobleBLE] Noble library loaded');
259
409
 
410
+ // Register the process-lifetime state listener before any early return:
411
+ // the poweredOn fast path below would otherwise skip it for the whole
412
+ // session, leaving poweredOff cache/state reconciliation dead.
413
+ setupPersistentStateListener();
414
+
260
415
  // Wait for Bluetooth to be ready
261
416
  await new Promise<void>((resolve, reject) => {
262
417
  if (!noble) {
@@ -269,16 +424,15 @@ async function initializeNoble(): Promise<void> {
269
424
  return;
270
425
  }
271
426
 
272
- // Setup persistent state listener before initialization
273
- setupPersistentStateListener();
274
-
275
427
  const timeout = setTimeout(() => {
428
+ cleanup();
276
429
  reject(
277
430
  ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
278
431
  );
279
432
  }, BLUETOOTH_INIT_TIMEOUT);
280
433
 
281
434
  const cleanup = () => {
435
+ pendingCancellations.delete(cancel);
282
436
  clearTimeout(timeout);
283
437
  if (noble) {
284
438
  noble.removeListener('stateChange', onStateChange);
@@ -303,9 +457,17 @@ async function initializeNoble(): Promise<void> {
303
457
  }
304
458
  };
305
459
 
460
+ const cancel = () => {
461
+ cleanup();
462
+ reject(
463
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
464
+ );
465
+ };
466
+ pendingCancellations.add(cancel);
306
467
  noble.on('stateChange', onStateChange);
307
468
  });
308
469
 
470
+ assertBleActive();
309
471
  // Set up device discovery
310
472
  if (!persistentDiscoverListener) {
311
473
  persistentDiscoverListener = (peripheral: Peripheral) => {
@@ -390,6 +552,7 @@ function armIdleDisconnect(
390
552
  reason: 'idle' | 'busy-backstop' = 'idle'
391
553
  ): void {
392
554
  clearIdleDisconnect(deviceId);
555
+ if (disposing) return;
393
556
  idleDisconnectTimers.set(
394
557
  deviceId,
395
558
  setTimeout(() => {
@@ -398,7 +561,21 @@ function armIdleDisconnect(
398
561
  logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
399
562
  const peripheral = connectedDevices.get(deviceId);
400
563
  const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
401
- const pending = disconnectDevice(deviceId)
564
+ // Unsubscribe CCCD before dropping the link, matching every other
565
+ // teardown path: the 1S leaves its notify session half-open when the
566
+ // link drops without an unsubscribe and then ignores application
567
+ // protocol traffic on NEW links for tens of minutes (field log
568
+ // 2026-08-19: the lone unsubscribe-skipping idle teardown caused a
569
+ // 6-attempt reconnect loop; unsubscribe-first teardowns reconnected
570
+ // instantly).
571
+ const pending = unsubscribeNotifications(deviceId)
572
+ .catch(error => {
573
+ logger?.warn('[NobleBLE] Keep-alive unsubscribe failed, disconnecting anyway', {
574
+ deviceId,
575
+ error: error instanceof Error ? error.message : String(error),
576
+ });
577
+ })
578
+ .then(() => disconnectDevice(deviceId))
402
579
  .then(() => {
403
580
  // A call is still in flight here; it must reject, not hang.
404
581
  // Both 'idle' and 'busy-backstop' report IdleKeepAlive on purpose:
@@ -458,6 +635,14 @@ function cleanupDevice(
458
635
 
459
636
  // 1. Clean up connection state
460
637
  if (cleanupConnection) {
638
+ connectingDevices
639
+ .get(deviceId)
640
+ ?.cancel(
641
+ ERRORS.TypedError(
642
+ HardwareErrorCode.BleDeviceDisconnected,
643
+ `Device ${deviceId} disconnected while connecting`
644
+ )
645
+ );
461
646
  const disconnectEntry = deviceDisconnectListeners.get(deviceId);
462
647
  if (disconnectEntry) {
463
648
  disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
@@ -508,6 +693,9 @@ function handleDeviceDisconnect(deviceId: string, webContents: WebContents): voi
508
693
 
509
694
  cleanupDevice(deviceId, webContents, {
510
695
  cleanupConnection: true,
696
+ // Same stale-object hazard as manual disconnect: an externally dropped
697
+ // link invalidates the cached peripheral for the next reconnect.
698
+ cleanupDiscoveredCache: true,
511
699
  sendDisconnectEvent: true,
512
700
  cancelOperations: true,
513
701
  reason: 'auto-disconnect',
@@ -544,12 +732,13 @@ function setupMtuListener(
544
732
  }
545
733
 
546
734
  const listener = (mtu: number) => {
547
- if (!Number.isFinite(mtu) || mtu <= 0) return;
735
+ const normalizedMtu = resolveNobleAttMtu(mtu);
736
+ if (normalizedMtu === undefined) return;
548
737
  // A kept-alive link can outlive the renderer this closure captured.
549
738
  if (webContents.isDestroyed()) return;
550
739
  webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
551
740
  id: deviceId,
552
- mtu,
741
+ mtu: normalizedMtu,
553
742
  });
554
743
  };
555
744
  deviceMtuListeners.set(deviceId, { peripheral, listener });
@@ -563,6 +752,7 @@ async function writeCharacteristicWithoutResponse(
563
752
  writeCharacteristic: Characteristic,
564
753
  buffer: Buffer
565
754
  ): Promise<void> {
755
+ assertBleActive();
566
756
  return new Promise((resolve, reject) => {
567
757
  writeCharacteristic.write(buffer, true, (error?: Error) => {
568
758
  if (error) {
@@ -697,7 +887,7 @@ async function transmitHexDataToDevice(
697
887
  const toBuffer = Buffer.from(hexData, 'hex');
698
888
  const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
699
889
  const packetCapacity = resolveBlePacketCapacity(
700
- peripheral.mtu,
890
+ resolveNobleAttMtu(peripheral.mtu),
701
891
  BLE_PACKET_SIZE_MAXIMUM,
702
892
  BLE_PACKET_SIZE_FALLBACK
703
893
  );
@@ -761,6 +951,44 @@ async function transmitHexDataToDevice(
761
951
  }
762
952
 
763
953
  // Handle discovered device (for general enumeration only)
954
+ // A scan that finds nothing costs this much before the direct-connect
955
+ // fallback runs. Every advertisement in the 6.5.0 control logs arrived within
956
+ // 631ms, so this leaves ~2x headroom while keeping a miss cheap.
957
+ const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
958
+
959
+ // Attempts before a link is written off as unable to serve the OneKey service.
960
+ const SERVICE_DISCOVERY_MAX_ATTEMPTS = 2;
961
+
962
+ // Advertised names, kept for the life of the process: device caches are purged
963
+ // on every disconnect, but the family a device belongs to never changes and
964
+ // decides which connection strategy is safe for it.
965
+ const bleNamesById = new Map<string, string>();
966
+
967
+ function rememberBleName(deviceId: string, name?: string | null): void {
968
+ const trimmed = name?.trim();
969
+ if (trimmed) {
970
+ bleNamesById.set(deviceId, trimmed);
971
+ }
972
+ }
973
+
974
+ function resolveBleName(deviceId: string): string | undefined {
975
+ const live =
976
+ connectedDevices.get(deviceId)?.advertisement?.localName?.trim() ||
977
+ discoveredDevices.get(deviceId)?.advertisement?.localName?.trim();
978
+ return live || bleNamesById.get(deviceId);
979
+ }
980
+
981
+ /**
982
+ * Pro2/Neo connect by id first: they are unaffected by the stale-session
983
+ * problem and advertise under Find My names a OneKey-filtered scan cannot
984
+ * match. Everything else — Classic family, and any device whose name is not
985
+ * known yet — scans first, because connecting by id can resolve a session the
986
+ * device no longer serves (see setupConnectionAndDiscoverServices).
987
+ */
988
+ function shouldConnectByIdFirst(deviceId: string): boolean {
989
+ return isPro2FamilyBleName(resolveBleName(deviceId));
990
+ }
991
+
764
992
  function handleDeviceDiscovered(peripheral: Peripheral): void {
765
993
  // Only process OneKey candidates for general discovery. Avoid logging every
766
994
  // ambient BLE peripheral; it makes Pro2 debugging hard to read.
@@ -770,6 +998,7 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
770
998
 
771
999
  const isNewDevice = !discoveredDevices.has(peripheral.id);
772
1000
  discoveredDevices.set(peripheral.id, peripheral);
1001
+ rememberBleName(peripheral.id, peripheral.advertisement?.localName);
773
1002
  if (isNewDevice) {
774
1003
  logger?.debug('[NobleBLE] OneKey BLE device discovered', {
775
1004
  deviceId: peripheral.id,
@@ -804,7 +1033,11 @@ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
804
1033
 
805
1034
  // Perform targeted scan for a specific device ID
806
1035
  // Uses self-contained local listener pattern - no global state needed
807
- async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
1036
+ async function performTargetedScan(
1037
+ targetDeviceId: string,
1038
+ timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
1039
+ ): Promise<Peripheral | null> {
1040
+ assertBleActive();
808
1041
  if (!noble) {
809
1042
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
810
1043
  }
@@ -820,6 +1053,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
820
1053
  const finish = async (peripheral: Peripheral | null, error?: Error) => {
821
1054
  if (settled) return;
822
1055
  settled = true;
1056
+ pendingCancellations.delete(cancel);
823
1057
  if (timeoutId) clearTimeout(timeoutId);
824
1058
  nobleInstance.removeListener('discover', onDiscover);
825
1059
  await waitForNobleScanStop(nobleInstance);
@@ -829,6 +1063,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
829
1063
  reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
830
1064
  return;
831
1065
  }
1066
+ assertBleActive();
832
1067
  if (peripheral) {
833
1068
  discoveredDevices.set(peripheral.id, peripheral);
834
1069
  }
@@ -849,7 +1084,12 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
849
1084
  const timeoutId = setTimeout(() => {
850
1085
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
851
1086
  finish(null).catch(reject);
852
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
1087
+ }, timeoutMs);
1088
+
1089
+ const cancel = () => {
1090
+ finish(null, new Error('Noble BLE is shutting down')).catch(reject);
1091
+ };
1092
+ pendingCancellations.add(cancel);
853
1093
 
854
1094
  // Add local listener for this scan
855
1095
  nobleInstance.on('discover', onDiscover);
@@ -876,6 +1116,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
876
1116
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
877
1117
  }
878
1118
 
1119
+ assertBleActive();
879
1120
  // Capture noble reference for use in closures (TypeScript narrowing)
880
1121
  const nobleInstance = noble;
881
1122
 
@@ -890,12 +1131,15 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
890
1131
 
891
1132
  return new Promise((resolve, reject) => {
892
1133
  const devices: DeviceInfo[] = [];
1134
+ let settled = false;
893
1135
  let intervalId: ReturnType<typeof setInterval> | undefined;
894
1136
 
895
1137
  // Cleanup function: clears timers and waits until Noble confirms scanning
896
1138
  // has stopped. Resolving enumerate before this callback creates a race with
897
1139
  // an immediately-following connection attempt.
898
1140
  const cleanup = async () => {
1141
+ settled = true;
1142
+ pendingCancellations.delete(cancel);
899
1143
  clearTimeout(timeoutId);
900
1144
  if (intervalId) clearInterval(intervalId);
901
1145
  await waitForNobleScanStop(nobleInstance);
@@ -919,6 +1163,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
919
1163
 
920
1164
  // Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
921
1165
  const timeoutId = setTimeout(async () => {
1166
+ if (settled) return;
922
1167
  // Final collection before resolving — catches devices discovered near the deadline
923
1168
  checkDevices();
924
1169
  await cleanup();
@@ -926,11 +1171,17 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
926
1171
  resolve(devices);
927
1172
  }, DEVICE_SCAN_TIMEOUT);
928
1173
 
1174
+ const cancel = () => {
1175
+ cleanup().then(() => resolve([]), reject);
1176
+ };
1177
+ pendingCancellations.add(cancel);
1178
+
929
1179
  // Start scanning without a service UUID filter so Pro2 advertisements with
930
1180
  // short vendor UUIDs can be found. Repeated advertisements are required when
931
1181
  // the local name arrives in a later scan response; discoveredDevices handles deduplication.
932
1182
  logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
933
1183
  nobleInstance.startScanning([], true, async (error?: Error) => {
1184
+ if (settled) return;
934
1185
  if (error) {
935
1186
  await cleanup();
936
1187
  logger?.error('[NobleBLE] Failed to start scanning:', error);
@@ -975,12 +1226,13 @@ function getDevice(deviceId: string): DeviceInfo | null {
975
1226
  const peripheral = discoveredDevices.get(deviceId);
976
1227
  if (peripheral) {
977
1228
  const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
1229
+ const mtu = resolveNobleAttMtu(peripheral.mtu);
978
1230
  return {
979
1231
  commType: 'electron-ble',
980
1232
  id: peripheral.id,
981
1233
  name: deviceName,
982
1234
  state: peripheral.state || 'disconnected',
983
- ...(typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}),
1235
+ ...(mtu === undefined ? {} : { mtu }),
984
1236
  };
985
1237
  }
986
1238
 
@@ -988,12 +1240,13 @@ function getDevice(deviceId: string): DeviceInfo | null {
988
1240
  const connectedPeripheral = connectedDevices.get(deviceId);
989
1241
  if (connectedPeripheral) {
990
1242
  const deviceName = connectedPeripheral.advertisement?.localName || 'Unknown Device';
1243
+ const mtu = resolveNobleAttMtu(connectedPeripheral.mtu);
991
1244
  return {
992
1245
  commType: 'electron-ble',
993
1246
  id: connectedPeripheral.id,
994
1247
  name: deviceName,
995
1248
  state: connectedPeripheral.state || 'connected',
996
- ...(typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}),
1249
+ ...(mtu === undefined ? {} : { mtu }),
997
1250
  };
998
1251
  }
999
1252
 
@@ -1019,13 +1272,17 @@ function getDevice(deviceId: string): DeviceInfo | null {
1019
1272
  async function discoverServicesAndCharacteristics(
1020
1273
  peripheral: Peripheral
1021
1274
  ): Promise<CharacteristicPair> {
1275
+ assertBleActive();
1022
1276
  // Cleanup resources - will be set up and cleaned in try/finally
1023
1277
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
1024
1278
  let onDisconnect: (() => void) | undefined;
1025
1279
 
1026
1280
  const cleanup = () => {
1027
1281
  if (timeoutId) clearTimeout(timeoutId);
1028
- if (onDisconnect) peripheral.removeListener('disconnect', onDisconnect);
1282
+ if (onDisconnect) {
1283
+ peripheral.removeListener('disconnect', onDisconnect);
1284
+ pendingCancellations.delete(onDisconnect);
1285
+ }
1029
1286
  };
1030
1287
 
1031
1288
  // Racing promises for timeout and disconnect
@@ -1046,14 +1303,23 @@ async function discoverServicesAndCharacteristics(
1046
1303
  )
1047
1304
  );
1048
1305
  };
1306
+ pendingCancellations.add(onDisconnect);
1049
1307
  peripheral.once('disconnect', onDisconnect);
1050
1308
  });
1051
1309
 
1052
1310
  // Main discovery logic as async function
1053
1311
  const discoveryPromise = (async (): Promise<CharacteristicPair> => {
1054
- // Step 1: Discover ALL services (no filter — Pro2 may use different service UUID)
1312
+ // Step 1: Discover the OneKey service by UUID. Filtering is not just a
1313
+ // narrowing: an unfiltered discovery is answered from the OS GATT cache, so
1314
+ // a device whose stack still advertises and accepts links but no longer
1315
+ // serves its application layer still looks healthy — the link comes up, the
1316
+ // cached services resolve, the protocol frame goes out and nothing ever
1317
+ // answers. A targeted query returns nothing in that state, which is what
1318
+ // 6.5.0 relied on to trip the recovery path below (retry, reset, fresh
1319
+ // scan) — the sequence that brings such a device back. Pro2/Neo expose the
1320
+ // same service UUID, and the selection below only ever accepts that one.
1055
1321
  const services = await new Promise<Service[]>((resolve, reject) => {
1056
- peripheral.discoverServices([], (error, svc) => {
1322
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
1057
1323
  if (error) {
1058
1324
  logger?.error('[NobleBLE] Service discovery failed:', error);
1059
1325
  reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
@@ -1063,8 +1329,9 @@ async function discoverServicesAndCharacteristics(
1063
1329
  });
1064
1330
  });
1065
1331
 
1332
+ assertBleActive();
1066
1333
  if (!services || services.length === 0) {
1067
- throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No services found');
1334
+ throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
1068
1335
  }
1069
1336
 
1070
1337
  logger?.debug('[NobleBLE] services discovered', {
@@ -1169,12 +1436,8 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1169
1436
  }
1170
1437
 
1171
1438
  // Step 3: Re-establish connection
1172
- await runBleCallbackOperation(callback => peripheral.connect(callback), {
1173
- timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1174
- timeoutBehavior: 'reject',
1175
- });
1439
+ await connectPeripheralWithCancellation(peripheral, deviceId);
1176
1440
  logger?.info('[NobleBLE] Force reconnect successful');
1177
- connectedDevices.set(deviceId, peripheral);
1178
1441
 
1179
1442
  // Wait for connection to stabilize
1180
1443
  await wait(500);
@@ -1210,21 +1473,11 @@ async function freshScanAndDiscover(
1210
1473
  discoveredDevices.set(deviceId, freshPeripheral);
1211
1474
 
1212
1475
  // Connect to fresh peripheral
1213
- await new Promise<void>((resolve, reject) => {
1214
- freshPeripheral.connect((error: Error | undefined) => {
1215
- if (error) {
1216
- reject(
1217
- ERRORS.TypedError(
1218
- HardwareErrorCode.BleConnectedError,
1219
- `Fresh peripheral connection failed: ${error.message}`
1220
- )
1221
- );
1222
- } else {
1223
- connectedDevices.set(deviceId, freshPeripheral);
1224
- resolve();
1225
- }
1226
- });
1227
- });
1476
+ await connectPeripheralWithCancellation(
1477
+ freshPeripheral,
1478
+ deviceId,
1479
+ 'Fresh peripheral connection failed: '
1480
+ );
1228
1481
 
1229
1482
  // Setup disconnect listener for fresh peripheral
1230
1483
  setupDisconnectListener(freshPeripheral, deviceId, webContents);
@@ -1248,12 +1501,14 @@ async function discoverServicesAndCharacteristicsWithRetry(
1248
1501
  deviceId,
1249
1502
  peripheralState: peripheral.state,
1250
1503
  attempt: attemptNumber,
1251
- maxRetries: 5,
1504
+ maxRetries: SERVICE_DISCOVERY_MAX_ATTEMPTS,
1252
1505
  targetUUIDs: ONEKEY_SERVICE_UUIDS,
1253
1506
  });
1254
1507
 
1255
1508
  if (attemptNumber > 1) {
1256
- logger?.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/5`);
1509
+ logger?.info(
1510
+ `[NobleBLE] Service discovery retry attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS}`
1511
+ );
1257
1512
  }
1258
1513
 
1259
1514
  // Verify connection state before attempting service discovery
@@ -1267,21 +1522,34 @@ async function discoverServicesAndCharacteristicsWithRetry(
1267
1522
  try {
1268
1523
  return await discoverServicesAndCharacteristics(peripheral);
1269
1524
  } catch (error) {
1270
- logger?.error(`[NobleBLE] No services found (attempt ${attemptNumber}/5)`);
1525
+ logger?.error(
1526
+ `[NobleBLE] No services found (attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1527
+ );
1271
1528
 
1272
- if (attemptNumber < 5) {
1273
- logger?.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/5)`);
1529
+ if (attemptNumber < SERVICE_DISCOVERY_MAX_ATTEMPTS) {
1530
+ logger?.error(
1531
+ `[NobleBLE] Will retry service discovery (attempt ${
1532
+ attemptNumber + 1
1533
+ }/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1534
+ );
1274
1535
  }
1275
1536
 
1276
1537
  throw error; // p-retry will handle the retry logic
1277
1538
  }
1278
1539
  },
1279
1540
  {
1280
- retries: 4, // Total 5 attempts (initial + 4 retries)
1281
- factor: 1.5, // Exponential backoff: 1000ms → 1500ms → 2250ms → 3000ms
1282
- minTimeout: 1000, // Start with 1 second delay
1283
- maxTimeout: 3000, // Maximum 3 seconds delay
1541
+ // One retry, not four. An empty result here is the signature of a device
1542
+ // whose stack no longer serves its application layer, and retrying the
1543
+ // same link has never recovered it — every observed run failed all the
1544
+ // way through. The recovery is the teardown and cold reconnect the caller
1545
+ // performs afterwards, so reaching it sooner is what shortens the wait.
1546
+ // The single retry stays for a genuinely transient miss.
1547
+ retries: SERVICE_DISCOVERY_MAX_ATTEMPTS - 1,
1548
+ factor: 1.5,
1549
+ minTimeout: 500,
1550
+ maxTimeout: 3000,
1284
1551
  onFailedAttempt: error => {
1552
+ assertBleActive();
1285
1553
  // This runs after each failed attempt
1286
1554
  logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
1287
1555
  message: error.message,
@@ -1305,26 +1573,52 @@ async function setupConnectionAndDiscoverServices(
1305
1573
  deviceId: string,
1306
1574
  webContents: WebContents
1307
1575
  ): Promise<CharacteristicPair> {
1308
- setupDisconnectListener(peripheral, deviceId, webContents);
1309
-
1576
+ // Reset the link before any protocol traffic reaches the device. 6.5.0 did
1577
+ // this on every cold setup and never produced a protocol-deaf Classic 1S
1578
+ // (control log 2026-08-20: 16/16 links answered within ~1s, including after
1579
+ // a 15-minute idle). Demoting it to a discovery-failure fallback removed the
1580
+ // reset entirely for this device, because discovery always succeeds: the link
1581
+ // comes up, GATT resolves from cache, and the device then answers nothing on
1582
+ // a session it no longer serves. Costs ~1.3s per cold setup; a kept-alive
1583
+ // link with an intact subscription returns before reaching here, so reuse
1584
+ // inside a workflow is unaffected.
1310
1585
  try {
1311
- return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1312
- } catch (initialError) {
1313
- logger?.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
1314
- deviceId,
1315
- error: initialError,
1316
- });
1586
+ await forceReconnectPeripheral(peripheral, deviceId);
1587
+ } catch (resetError) {
1588
+ if (
1589
+ isBleStaleBondHardwareError(resetError) ||
1590
+ (resetError as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected
1591
+ ) {
1592
+ throw resetError;
1593
+ }
1594
+ // A failed reset must not abort the attempt: discovery on the existing
1595
+ // link, then the fresh-scan fallback, still have a chance to recover.
1596
+ logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1317
1597
  }
1318
-
1319
- await forceReconnectPeripheral(peripheral, deviceId);
1598
+ assertBleActive();
1320
1599
  setupDisconnectListener(peripheral, deviceId, webContents);
1600
+
1321
1601
  try {
1322
1602
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1323
- } catch (reconnectError) {
1324
- logger?.error(
1325
- '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1326
- reconnectError
1327
- );
1603
+ } catch (discoveryError) {
1604
+ // A connected peripheral does not advertise, so the fresh scan below would
1605
+ // only run out its timeout. Drop the link first, then rescan: the cold
1606
+ // reconnect is what recovers a device whose stack stopped serving its
1607
+ // application layer. Recover here rather than throwing to the caller —
1608
+ // one acquire pays ~2s for the rescan, while a thrown error costs the
1609
+ // renderer a full retry round trip.
1610
+ if (peripheral.state === 'connected') {
1611
+ logger?.error(
1612
+ '[NobleBLE] Service discovery failed on a live link, dropping it before the fresh scan',
1613
+ discoveryError
1614
+ );
1615
+ await disconnectDevice(deviceId).catch(() => undefined);
1616
+ } else {
1617
+ logger?.error(
1618
+ '[NobleBLE] Service discovery failed, attempting fresh scan...',
1619
+ discoveryError
1620
+ );
1621
+ }
1328
1622
  return freshScanAndDiscover(deviceId, webContents);
1329
1623
  }
1330
1624
  }
@@ -1344,6 +1638,7 @@ const directConnectCooldownUntil = new Map<string, number>();
1344
1638
  * Ported from the Trezor connector, where it is field-proven.
1345
1639
  */
1346
1640
  async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
1641
+ assertBleActive();
1347
1642
  const nobleInstance = noble as
1348
1643
  | (typeof noble & {
1349
1644
  connectAsync?: (id: string) => Promise<Peripheral | undefined>;
@@ -1354,16 +1649,20 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1354
1649
  const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
1355
1650
  if (Date.now() < cooldownUntil) return undefined;
1356
1651
 
1652
+ let timer: ReturnType<typeof setTimeout> | undefined;
1653
+ let cancel: (() => void) | undefined;
1357
1654
  try {
1358
1655
  // The late-orphan guard must attach to THIS pending connect.
1359
1656
  const directPromise = nobleInstance.connectAsync(deviceId);
1360
1657
  const raced = await Promise.race([
1361
1658
  directPromise,
1362
1659
  new Promise<'timeout'>(resolve => {
1363
- setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
1660
+ cancel = () => resolve('timeout');
1661
+ pendingCancellations.add(cancel);
1662
+ timer = setTimeout(cancel, DIRECT_CONNECT_TIMEOUT_MS);
1364
1663
  }),
1365
1664
  ]);
1366
- if (raced === 'timeout') {
1665
+ if (raced === 'timeout' || disposing) {
1367
1666
  directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1368
1667
  logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1369
1668
  deviceId,
@@ -1396,6 +1695,9 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1396
1695
  error: String(error),
1397
1696
  });
1398
1697
  return undefined;
1698
+ } finally {
1699
+ clearTimeout(timer);
1700
+ if (cancel) pendingCancellations.delete(cancel);
1399
1701
  }
1400
1702
  }
1401
1703
 
@@ -1413,10 +1715,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1413
1715
  // enumerate clears the discovery map; a kept-alive link outlives it.
1414
1716
  let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1415
1717
 
1416
- // If device not discovered, try a targeted scan for this specific device
1417
1718
  if (!peripheral) {
1418
- logger?.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1419
-
1420
1719
  // Initialize Noble if not already done
1421
1720
  if (!noble) {
1422
1721
  await initializeNoble();
@@ -1426,35 +1725,59 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1426
1725
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1427
1726
  }
1428
1727
 
1429
- // Reaches a bonded device that is not advertising; else falls to the scan.
1430
- peripheral = await tryDirectConnectById(deviceId);
1431
- if (peripheral) {
1432
- discoveredDevices.set(deviceId, peripheral);
1433
- }
1434
- }
1728
+ // A live advertisement proves the device holds no link and will open a
1729
+ // fresh session; connecting by id resolves through the OS cache instead
1730
+ // and can hand back a session the device no longer serves (see
1731
+ // setupConnectionAndDiscoverServices). Scanning is also the faster of the
1732
+ // two when the device does advertise: ~96ms median against ~784ms for
1733
+ // connect-by-id across the field logs. Pro2/Neo keep connect-by-id first
1734
+ // because they are unaffected and may advertise under an unmatchable Find
1735
+ // My name.
1736
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1737
+ logger?.info('[NobleBLE] Resolving device for cold connect', {
1738
+ deviceId,
1739
+ name: resolveBleName(deviceId) ?? 'unknown',
1740
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1741
+ });
1435
1742
 
1436
- if (!peripheral) {
1437
- // Perform a targeted scan to find the specific device
1438
- try {
1439
- const foundPeripheral = await performTargetedScan(deviceId);
1440
- if (!foundPeripheral) {
1441
- throw ERRORS.TypedError(
1442
- HardwareErrorCode.DeviceNotFound,
1443
- `Device ${deviceId} not found even after targeted scan`
1444
- );
1743
+ const scanForPeripheral = async () => {
1744
+ try {
1745
+ return (await performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS)) ?? undefined;
1746
+ } catch (scanError) {
1747
+ logger?.info('[NobleBLE] Targeted scan failed', {
1748
+ deviceId,
1749
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1750
+ });
1751
+ return undefined;
1445
1752
  }
1446
- peripheral = foundPeripheral;
1447
- } catch (error) {
1448
- logger?.error('[NobleBLE] Targeted scan failed:', error);
1449
- throw error;
1753
+ };
1754
+
1755
+ const connectById = async () => {
1756
+ const found = await tryDirectConnectById(deviceId);
1757
+ if (found) {
1758
+ discoveredDevices.set(deviceId, found);
1759
+ }
1760
+ return found;
1761
+ };
1762
+
1763
+ peripheral = byIdFirst ? await connectById() : await scanForPeripheral();
1764
+ if (!peripheral) {
1765
+ // The preferred route came up empty: not advertising (held elsewhere, or
1766
+ // silent), or not reachable by id. Try the other one before giving up.
1767
+ peripheral = byIdFirst ? await scanForPeripheral() : await connectById();
1450
1768
  }
1451
1769
  }
1452
1770
 
1771
+ assertBleActive();
1453
1772
  // At this point, peripheral is guaranteed to be defined
1454
1773
  if (!peripheral) {
1455
1774
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1456
1775
  }
1457
1776
 
1777
+ // Also covers the connect-by-id route, whose simulated discovery carries no
1778
+ // service UUIDs and therefore never reaches handleDeviceDiscovered.
1779
+ rememberBleName(deviceId, peripheral.advertisement?.localName);
1780
+
1458
1781
  logger?.info('[NobleBLE] Connecting to device:', deviceId);
1459
1782
 
1460
1783
  // Check if device is already connected
@@ -1468,6 +1791,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1468
1791
  // Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
1469
1792
  // and MTU listeners may still hold the webContents of a soft-restarted
1470
1793
  // renderer, and the reuse fast path below returns before any other setup.
1794
+ assertBleActive();
1471
1795
  setupDisconnectListener(peripheral, deviceId, webContents);
1472
1796
 
1473
1797
  // Check if we already have characteristics for this device
@@ -1518,6 +1842,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1518
1842
  deviceId,
1519
1843
  webContents
1520
1844
  );
1845
+ assertBleActive();
1521
1846
  deviceCharacteristics.set(deviceId, characteristics);
1522
1847
  logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1523
1848
  } catch (setupError) {
@@ -1529,67 +1854,42 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1529
1854
  return;
1530
1855
  }
1531
1856
 
1532
- return new Promise((resolve, reject) => {
1533
- let connectionTimedOut = false;
1534
- const timeout = setTimeout(() => {
1535
- connectionTimedOut = true;
1536
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
1537
- }, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
1538
-
1539
- // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1540
- const connectedPeripheral = peripheral as Peripheral;
1541
- connectedPeripheral.connect(async (error: Error | undefined) => {
1542
- clearTimeout(timeout);
1543
-
1544
- // Noble may invoke the callback after the SDK timed out and released the request.
1545
- // Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
1546
- if (connectionTimedOut) {
1547
- if (!error) {
1548
- try {
1549
- connectedPeripheral.disconnect(() => undefined);
1550
- } catch {
1551
- // Best-effort cleanup only.
1552
- }
1553
- }
1554
- return;
1555
- }
1556
-
1557
- if (error) {
1558
- logger?.error('[NobleBLE] Connection failed:', error);
1559
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1560
- return;
1561
- }
1562
-
1563
- logger?.info('[NobleBLE] Connected to device:', deviceId);
1564
- connectedDevices.set(deviceId, connectedPeripheral);
1857
+ const connectedPeripheral = peripheral;
1858
+ await connectPeripheralWithCancellation(connectedPeripheral, deviceId);
1859
+ logger?.info('[NobleBLE] Connected to device:', deviceId);
1565
1860
 
1566
- // Setup connection and discover services
1567
- try {
1568
- const characteristics = await setupConnectionAndDiscoverServices(
1569
- connectedPeripheral,
1570
- deviceId,
1571
- webContents
1572
- );
1573
- deviceCharacteristics.set(deviceId, characteristics);
1574
- logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1575
- resolve();
1576
- } catch (setupError) {
1577
- logger?.error('[NobleBLE] Connection setup failed:', setupError);
1578
- connectedPeripheral.disconnect(() => {
1579
- reject(setupError);
1580
- });
1581
- }
1582
- });
1583
- });
1861
+ // Setup connection and discover services
1862
+ try {
1863
+ const characteristics = await setupConnectionAndDiscoverServices(
1864
+ connectedPeripheral,
1865
+ deviceId,
1866
+ webContents
1867
+ );
1868
+ assertBleActive();
1869
+ deviceCharacteristics.set(deviceId, characteristics);
1870
+ logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1871
+ } catch (setupError) {
1872
+ logger?.error('[NobleBLE] Connection setup failed:', setupError);
1873
+ await disconnectDevice(deviceId).catch(() => undefined);
1874
+ throw setupError;
1875
+ }
1584
1876
  }
1585
1877
 
1586
1878
  // Disconnect device
1587
1879
  async function disconnectDevice(deviceId: string): Promise<void> {
1588
- const peripheral = connectedDevices.get(deviceId);
1880
+ const pendingConnection = connectingDevices.get(deviceId);
1881
+ const peripheral = connectedDevices.get(deviceId) ?? pendingConnection?.peripheral;
1589
1882
  if (!peripheral) {
1590
1883
  return;
1591
1884
  }
1592
1885
 
1886
+ pendingConnection?.cancel(
1887
+ ERRORS.TypedError(
1888
+ HardwareErrorCode.BleDeviceDisconnected,
1889
+ `Device ${deviceId} connection cancelled`
1890
+ )
1891
+ );
1892
+
1593
1893
  const disconnectEntry = deviceDisconnectListeners.get(deviceId);
1594
1894
  if (disconnectEntry) {
1595
1895
  disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
@@ -1597,11 +1897,18 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1597
1897
  }
1598
1898
 
1599
1899
  await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1600
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1900
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1601
1901
  timeoutBehavior: 'resolve',
1602
1902
  });
1903
+ if (peripheral.state === 'connected') {
1904
+ logger?.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1905
+ }
1603
1906
  cleanupDevice(deviceId, undefined, {
1604
1907
  cleanupConnection: true,
1908
+ // macOS returns zero GATT services when a previously-disconnected
1909
+ // peripheral object is reconnected; drop the discovery cache so the next
1910
+ // connect resolves a fresh peripheral (direct connect by id, or scan).
1911
+ cleanupDiscoveredCache: true,
1605
1912
  sendDisconnectEvent: false,
1606
1913
  cancelOperations: true,
1607
1914
  reason: 'manual-disconnect',
@@ -1637,7 +1944,7 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1637
1944
  subscribedDevices.delete(deviceId);
1638
1945
  } finally {
1639
1946
  // 🔒 CRITICAL: Always clear operation state (even on error)
1640
- subscriptionOperations.set(deviceId, 'idle');
1947
+ if (!disposing) subscriptionOperations.set(deviceId, 'idle');
1641
1948
  }
1642
1949
  }
1643
1950
 
@@ -1726,11 +2033,13 @@ async function subscribeNotifications(
1726
2033
  timeoutMs: BLE_CLEANUP_TIMEOUT,
1727
2034
  timeoutBehavior: 'resolve',
1728
2035
  });
2036
+ assertBleActive();
1729
2037
  await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1730
- timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
2038
+ timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
1731
2039
  timeoutBehavior: 'reject',
1732
2040
  });
1733
2041
 
2042
+ assertBleActive();
1734
2043
  notifyCharacteristic.on('data', (data: Buffer) => {
1735
2044
  // Windows BLE pairing detection: receiving any data means device is paired
1736
2045
  if (!pairedDevices.has(deviceId)) {
@@ -1742,17 +2051,29 @@ async function subscribeNotifications(
1742
2051
  });
1743
2052
  }
1744
2053
 
2054
+ const subscribeStartedAt = Date.now();
1745
2055
  try {
1746
2056
  await rebuildAppSubscription(deviceId, notifyCharacteristic);
2057
+ assertBleActive();
1747
2058
  subscribedDevices.set(deviceId, true);
2059
+ logger?.info('[NobleBLE] Notification subscription active', {
2060
+ deviceId,
2061
+ ms: Date.now() - subscribeStartedAt,
2062
+ });
2063
+ } catch (error) {
2064
+ throw createNobleBleConnectionError(
2065
+ error as NobleBleNativeError,
2066
+ 'Notification subscription failed: '
2067
+ );
1748
2068
  } finally {
1749
2069
  // 🔒 CRITICAL: Always clear operation state (even on error)
1750
- subscriptionOperations.set(deviceId, 'idle');
2070
+ if (!disposing) subscriptionOperations.set(deviceId, 'idle');
1751
2071
  }
1752
2072
  }
1753
2073
 
1754
2074
  // Setup IPC handlers
1755
2075
  export function setupNobleBleHandlers(webContents: WebContents): void {
2076
+ if (disposing) return;
1756
2077
  try {
1757
2078
  // @ts-ignore – electron-log is only available at runtime
1758
2079
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
@@ -1762,10 +2083,21 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1762
2083
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1763
2084
  const { ipcMain } = require('electron') as { ipcMain: IpcMain };
1764
2085
 
2086
+ const channels = new Set<string>();
2087
+ removeIpcHandlers = () => channels.forEach(channel => ipcMain.removeHandler(channel));
2088
+
1765
2089
  // Electron throws on duplicate channels and setup re-runs on soft restart.
1766
2090
  const handle: IpcMain['handle'] = (channel, listener) => {
2091
+ channels.add(channel);
1767
2092
  ipcMain.removeHandler(channel);
1768
- ipcMain.handle(channel, listener);
2093
+ ipcMain.handle(channel, async (...args) => {
2094
+ try {
2095
+ assertBleActive();
2096
+ return await Promise.resolve(listener(...args));
2097
+ } catch (error) {
2098
+ return createNobleBleIpcErrorResponse(error);
2099
+ }
2100
+ });
1769
2101
  };
1770
2102
 
1771
2103
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
@@ -1916,8 +2248,9 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1916
2248
  }
1917
2249
  });
1918
2250
 
1919
- // Cleanup on app quit
2251
+ // Window cleanup also runs during renderer soft restart; native release belongs to app quit.
1920
2252
  webContents.on('destroyed', () => {
2253
+ if (disposing) return;
1921
2254
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1922
2255
  (async () => {
1923
2256
  const deviceIds = Array.from(connectedDevices.keys());
@@ -1927,10 +2260,12 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1927
2260
  }
1928
2261
 
1929
2262
  await stopScanning().catch(() => undefined);
1930
- if (noble && persistentStateListener) {
1931
- noble.removeListener('stateChange', persistentStateListener);
1932
- persistentStateListener = null;
1933
- }
2263
+ // persistentStateListener is process-lifetime, NOT per-window: this
2264
+ // destroy handler also fires on a renderer soft restart, and removing
2265
+ // the listener here permanently killed poweredOff/poweredOn
2266
+ // reconciliation for the rest of the process (nothing re-registers it:
2267
+ // initializeNoble early-returns once noble is loaded). The null-guard
2268
+ // in setupPersistentStateListener keeps it deduped to one instance.
1934
2269
  cleanupNobleListeners();
1935
2270
  discoveredDevices.clear();
1936
2271
  safeLog(logger, 'info', 'Noble BLE cleanup completed');
@@ -1945,3 +2280,65 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1945
2280
  throw error;
1946
2281
  }
1947
2282
  }
2283
+
2284
+ /**
2285
+ * Terminal, bounded cleanup while the Node environment is still alive.
2286
+ * If Noble is shared with another transport, releaseNoble must queue the instance
2287
+ * and call stop() once after every transport has finished its cleanup.
2288
+ */
2289
+ export function disposeNobleBleSupport(
2290
+ releaseNoble: (instance: { stop(): void }) => void = instance => instance.stop()
2291
+ ): Promise<void> {
2292
+ if (disposePromise) return disposePromise;
2293
+ disposing = true;
2294
+ removeIpcHandlers?.();
2295
+ const instance = noble;
2296
+ const deviceIds = new Set([...connectedDevices.keys(), ...connectingDevices.keys()]);
2297
+ const pendingPeripherals = Array.from(connectingDevices.values(), pending => pending.peripheral);
2298
+ for (const cancel of pendingCancellations) cancel();
2299
+ for (const id of idleDisconnectTimers.keys()) clearIdleDisconnect(id);
2300
+ for (const pending of connectingDevices.values()) {
2301
+ pending.cancel(
2302
+ ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
2303
+ );
2304
+ }
2305
+ let timeout: ReturnType<typeof setTimeout> | undefined;
2306
+ disposePromise = (async () => {
2307
+ try {
2308
+ await Promise.race([
2309
+ Promise.allSettled([
2310
+ stopScanning(),
2311
+ ...pendingPeripherals.map(peripheral =>
2312
+ runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
2313
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
2314
+ timeoutBehavior: 'resolve',
2315
+ })
2316
+ ),
2317
+ ...Array.from(deviceIds, async id => {
2318
+ await unsubscribeNotifications(id).catch(() => undefined);
2319
+ await disconnectDevice(id).catch(() => undefined);
2320
+ }),
2321
+ ]),
2322
+ new Promise<void>(resolve => {
2323
+ timeout = setTimeout(() => {
2324
+ logger?.warn('[NobleBLE] Process dispose timed out; releasing native manager');
2325
+ resolve();
2326
+ }, 3500);
2327
+ }),
2328
+ ]);
2329
+ } finally {
2330
+ clearTimeout(timeout);
2331
+ cleanupNobleListeners();
2332
+ if (instance && persistentStateListener) {
2333
+ instance.removeListener('stateChange', persistentStateListener);
2334
+ persistentStateListener = null;
2335
+ }
2336
+ for (const id of deviceIds) cleanupDevice(id);
2337
+ discoveredDevices.clear();
2338
+ directConnectCooldownUntil.clear();
2339
+ if (instance) releaseNoble(instance);
2340
+ logger?.info('[NobleBLE] Process dispose completed');
2341
+ }
2342
+ })();
2343
+ return disposePromise;
2344
+ }