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

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,34 +10,94 @@ 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;
40
43
 
44
+ type NobleBleNativeError = Error & {
45
+ nativeErrorCode?: number;
46
+ nativeErrorDomain?: string;
47
+ };
48
+
49
+ export function createNobleBleConnectionError(error: NobleBleNativeError, messagePrefix = '') {
50
+ const errorMessage = error.message;
51
+ const isInvalidMacOsBond =
52
+ error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain';
53
+ if (isInvalidMacOsBond) {
54
+ const nativeErrorMessage = `${messagePrefix}${errorMessage}`;
55
+ return ERRORS.TypedError(
56
+ HardwareErrorCode.BleBondInvalid,
57
+ `${HardwareErrorCodeMessage[HardwareErrorCode.BleBondInvalid]} (${nativeErrorMessage})`,
58
+ {
59
+ nativeErrorMessage,
60
+ }
61
+ );
62
+ }
63
+
64
+ return ERRORS.TypedError(HardwareErrorCode.BleConnectedError, `${messagePrefix}${errorMessage}`);
65
+ }
66
+
67
+ export function createNobleBleIpcErrorResponse(error: unknown): NobleBleIpcErrorResponse {
68
+ const candidate = error as {
69
+ errorCode?: unknown;
70
+ message?: unknown;
71
+ name?: unknown;
72
+ params?: unknown;
73
+ };
74
+ const errorCode =
75
+ typeof candidate?.errorCode === 'number' ? candidate.errorCode : HardwareErrorCode.UnknownError;
76
+ const message =
77
+ typeof candidate?.message === 'string' ? candidate.message : String(error ?? 'Unknown error');
78
+ const name = typeof candidate?.name === 'string' ? candidate.name : 'Error';
79
+ let params: unknown;
80
+ if (candidate?.params !== undefined) {
81
+ try {
82
+ const serializedParams = JSON.stringify(candidate.params);
83
+ params = serializedParams === undefined ? undefined : JSON.parse(serializedParams);
84
+ } catch {
85
+ params = undefined;
86
+ }
87
+ }
88
+
89
+ return {
90
+ type: 'NobleBleIpcError',
91
+ success: false,
92
+ error: {
93
+ name,
94
+ message,
95
+ errorCode,
96
+ ...(params !== undefined ? { params } : {}),
97
+ },
98
+ };
99
+ }
100
+
41
101
  // Bluetooth state management
42
102
  const bluetoothState: {
43
103
  available: boolean;
@@ -90,8 +150,18 @@ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
90
150
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
91
151
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
92
152
  const BLE_CLEANUP_TIMEOUT = 250;
153
+ // A physical teardown must actually reach the OS before the device can return
154
+ // to a clean state; 250ms routinely declared success while CoreBluetooth was
155
+ // still disconnecting, leaving no trace when the teardown never completed.
156
+ const BLE_DISCONNECT_CONFIRM_TIMEOUT_MS = 3000;
93
157
  // Renderer release is logical only; this timer physically frees the device.
94
- const BLE_IDLE_DISCONNECT_MS = 3 * 60_000;
158
+ // Keep it SHORT: the Classic 1S goes protocol-deaf when a link is dropped
159
+ // after sitting idle for minutes (field logs 2026-08-19: every deaf window
160
+ // followed a 3-minute-idle disconnect, while disconnects right after traffic
161
+ // have never produced one across 6.5.0's per-call teardown history). A hot
162
+ // disconnect ~20s after the last operation stays inside the proven-safe
163
+ // pattern and also shrinks the window in which phones cannot see the device.
164
+ const BLE_IDLE_DISCONNECT_MS = 20_000;
95
165
  // Ceiling while a call is in flight: no outstanding write, but not forever.
96
166
  const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
97
167
 
@@ -257,6 +327,11 @@ async function initializeNoble(): Promise<void> {
257
327
  noble = require('@stoprocent/noble') as NobleModule;
258
328
  logger?.info('[NobleBLE] Noble library loaded');
259
329
 
330
+ // Register the process-lifetime state listener before any early return:
331
+ // the poweredOn fast path below would otherwise skip it for the whole
332
+ // session, leaving poweredOff cache/state reconciliation dead.
333
+ setupPersistentStateListener();
334
+
260
335
  // Wait for Bluetooth to be ready
261
336
  await new Promise<void>((resolve, reject) => {
262
337
  if (!noble) {
@@ -269,9 +344,6 @@ async function initializeNoble(): Promise<void> {
269
344
  return;
270
345
  }
271
346
 
272
- // Setup persistent state listener before initialization
273
- setupPersistentStateListener();
274
-
275
347
  const timeout = setTimeout(() => {
276
348
  reject(
277
349
  ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
@@ -398,7 +470,21 @@ function armIdleDisconnect(
398
470
  logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
399
471
  const peripheral = connectedDevices.get(deviceId);
400
472
  const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
401
- const pending = disconnectDevice(deviceId)
473
+ // Unsubscribe CCCD before dropping the link, matching every other
474
+ // teardown path: the 1S leaves its notify session half-open when the
475
+ // link drops without an unsubscribe and then ignores application
476
+ // protocol traffic on NEW links for tens of minutes (field log
477
+ // 2026-08-19: the lone unsubscribe-skipping idle teardown caused a
478
+ // 6-attempt reconnect loop; unsubscribe-first teardowns reconnected
479
+ // instantly).
480
+ const pending = unsubscribeNotifications(deviceId)
481
+ .catch(error => {
482
+ logger?.warn('[NobleBLE] Keep-alive unsubscribe failed, disconnecting anyway', {
483
+ deviceId,
484
+ error: error instanceof Error ? error.message : String(error),
485
+ });
486
+ })
487
+ .then(() => disconnectDevice(deviceId))
402
488
  .then(() => {
403
489
  // A call is still in flight here; it must reject, not hang.
404
490
  // Both 'idle' and 'busy-backstop' report IdleKeepAlive on purpose:
@@ -508,6 +594,9 @@ function handleDeviceDisconnect(deviceId: string, webContents: WebContents): voi
508
594
 
509
595
  cleanupDevice(deviceId, webContents, {
510
596
  cleanupConnection: true,
597
+ // Same stale-object hazard as manual disconnect: an externally dropped
598
+ // link invalidates the cached peripheral for the next reconnect.
599
+ cleanupDiscoveredCache: true,
511
600
  sendDisconnectEvent: true,
512
601
  cancelOperations: true,
513
602
  reason: 'auto-disconnect',
@@ -544,12 +633,13 @@ function setupMtuListener(
544
633
  }
545
634
 
546
635
  const listener = (mtu: number) => {
547
- if (!Number.isFinite(mtu) || mtu <= 0) return;
636
+ const normalizedMtu = resolveNobleAttMtu(mtu);
637
+ if (normalizedMtu === undefined) return;
548
638
  // A kept-alive link can outlive the renderer this closure captured.
549
639
  if (webContents.isDestroyed()) return;
550
640
  webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
551
641
  id: deviceId,
552
- mtu,
642
+ mtu: normalizedMtu,
553
643
  });
554
644
  };
555
645
  deviceMtuListeners.set(deviceId, { peripheral, listener });
@@ -697,7 +787,7 @@ async function transmitHexDataToDevice(
697
787
  const toBuffer = Buffer.from(hexData, 'hex');
698
788
  const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
699
789
  const packetCapacity = resolveBlePacketCapacity(
700
- peripheral.mtu,
790
+ resolveNobleAttMtu(peripheral.mtu),
701
791
  BLE_PACKET_SIZE_MAXIMUM,
702
792
  BLE_PACKET_SIZE_FALLBACK
703
793
  );
@@ -761,6 +851,44 @@ async function transmitHexDataToDevice(
761
851
  }
762
852
 
763
853
  // Handle discovered device (for general enumeration only)
854
+ // A scan that finds nothing costs this much before the direct-connect
855
+ // fallback runs. Every advertisement in the 6.5.0 control logs arrived within
856
+ // 631ms, so this leaves ~2x headroom while keeping a miss cheap.
857
+ const BLE_COLD_CONNECT_SCAN_TIMEOUT_MS = 1500;
858
+
859
+ // Attempts before a link is written off as unable to serve the OneKey service.
860
+ const SERVICE_DISCOVERY_MAX_ATTEMPTS = 2;
861
+
862
+ // Advertised names, kept for the life of the process: device caches are purged
863
+ // on every disconnect, but the family a device belongs to never changes and
864
+ // decides which connection strategy is safe for it.
865
+ const bleNamesById = new Map<string, string>();
866
+
867
+ function rememberBleName(deviceId: string, name?: string | null): void {
868
+ const trimmed = name?.trim();
869
+ if (trimmed) {
870
+ bleNamesById.set(deviceId, trimmed);
871
+ }
872
+ }
873
+
874
+ function resolveBleName(deviceId: string): string | undefined {
875
+ const live =
876
+ connectedDevices.get(deviceId)?.advertisement?.localName?.trim() ||
877
+ discoveredDevices.get(deviceId)?.advertisement?.localName?.trim();
878
+ return live || bleNamesById.get(deviceId);
879
+ }
880
+
881
+ /**
882
+ * Pro2/Neo connect by id first: they are unaffected by the stale-session
883
+ * problem and advertise under Find My names a OneKey-filtered scan cannot
884
+ * match. Everything else — Classic family, and any device whose name is not
885
+ * known yet — scans first, because connecting by id can resolve a session the
886
+ * device no longer serves (see setupConnectionAndDiscoverServices).
887
+ */
888
+ function shouldConnectByIdFirst(deviceId: string): boolean {
889
+ return isPro2FamilyBleName(resolveBleName(deviceId));
890
+ }
891
+
764
892
  function handleDeviceDiscovered(peripheral: Peripheral): void {
765
893
  // Only process OneKey candidates for general discovery. Avoid logging every
766
894
  // ambient BLE peripheral; it makes Pro2 debugging hard to read.
@@ -770,6 +898,7 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
770
898
 
771
899
  const isNewDevice = !discoveredDevices.has(peripheral.id);
772
900
  discoveredDevices.set(peripheral.id, peripheral);
901
+ rememberBleName(peripheral.id, peripheral.advertisement?.localName);
773
902
  if (isNewDevice) {
774
903
  logger?.debug('[NobleBLE] OneKey BLE device discovered', {
775
904
  deviceId: peripheral.id,
@@ -804,7 +933,10 @@ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
804
933
 
805
934
  // Perform targeted scan for a specific device ID
806
935
  // Uses self-contained local listener pattern - no global state needed
807
- async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
936
+ async function performTargetedScan(
937
+ targetDeviceId: string,
938
+ timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
939
+ ): Promise<Peripheral | null> {
808
940
  if (!noble) {
809
941
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
810
942
  }
@@ -849,7 +981,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
849
981
  const timeoutId = setTimeout(() => {
850
982
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
851
983
  finish(null).catch(reject);
852
- }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
984
+ }, timeoutMs);
853
985
 
854
986
  // Add local listener for this scan
855
987
  nobleInstance.on('discover', onDiscover);
@@ -975,12 +1107,13 @@ function getDevice(deviceId: string): DeviceInfo | null {
975
1107
  const peripheral = discoveredDevices.get(deviceId);
976
1108
  if (peripheral) {
977
1109
  const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
1110
+ const mtu = resolveNobleAttMtu(peripheral.mtu);
978
1111
  return {
979
1112
  commType: 'electron-ble',
980
1113
  id: peripheral.id,
981
1114
  name: deviceName,
982
1115
  state: peripheral.state || 'disconnected',
983
- ...(typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}),
1116
+ ...(mtu === undefined ? {} : { mtu }),
984
1117
  };
985
1118
  }
986
1119
 
@@ -988,12 +1121,13 @@ function getDevice(deviceId: string): DeviceInfo | null {
988
1121
  const connectedPeripheral = connectedDevices.get(deviceId);
989
1122
  if (connectedPeripheral) {
990
1123
  const deviceName = connectedPeripheral.advertisement?.localName || 'Unknown Device';
1124
+ const mtu = resolveNobleAttMtu(connectedPeripheral.mtu);
991
1125
  return {
992
1126
  commType: 'electron-ble',
993
1127
  id: connectedPeripheral.id,
994
1128
  name: deviceName,
995
1129
  state: connectedPeripheral.state || 'connected',
996
- ...(typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}),
1130
+ ...(mtu === undefined ? {} : { mtu }),
997
1131
  };
998
1132
  }
999
1133
 
@@ -1051,9 +1185,17 @@ async function discoverServicesAndCharacteristics(
1051
1185
 
1052
1186
  // Main discovery logic as async function
1053
1187
  const discoveryPromise = (async (): Promise<CharacteristicPair> => {
1054
- // Step 1: Discover ALL services (no filter — Pro2 may use different service UUID)
1188
+ // Step 1: Discover the OneKey service by UUID. Filtering is not just a
1189
+ // narrowing: an unfiltered discovery is answered from the OS GATT cache, so
1190
+ // a device whose stack still advertises and accepts links but no longer
1191
+ // serves its application layer still looks healthy — the link comes up, the
1192
+ // cached services resolve, the protocol frame goes out and nothing ever
1193
+ // answers. A targeted query returns nothing in that state, which is what
1194
+ // 6.5.0 relied on to trip the recovery path below (retry, reset, fresh
1195
+ // scan) — the sequence that brings such a device back. Pro2/Neo expose the
1196
+ // same service UUID, and the selection below only ever accepts that one.
1055
1197
  const services = await new Promise<Service[]>((resolve, reject) => {
1056
- peripheral.discoverServices([], (error, svc) => {
1198
+ peripheral.discoverServices(ONEKEY_SERVICE_UUIDS, (error, svc) => {
1057
1199
  if (error) {
1058
1200
  logger?.error('[NobleBLE] Service discovery failed:', error);
1059
1201
  reject(ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, error.message));
@@ -1064,7 +1206,7 @@ async function discoverServicesAndCharacteristics(
1064
1206
  });
1065
1207
 
1066
1208
  if (!services || services.length === 0) {
1067
- throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No services found');
1209
+ throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
1068
1210
  }
1069
1211
 
1070
1212
  logger?.debug('[NobleBLE] services discovered', {
@@ -1169,9 +1311,14 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1169
1311
  }
1170
1312
 
1171
1313
  // Step 3: Re-establish connection
1172
- await runBleCallbackOperation(callback => peripheral.connect(callback), {
1173
- timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1174
- timeoutBehavior: 'reject',
1314
+ await new Promise<void>((resolve, reject) => {
1315
+ peripheral.connect(error => {
1316
+ if (error) {
1317
+ reject(createNobleBleConnectionError(error));
1318
+ return;
1319
+ }
1320
+ resolve();
1321
+ });
1175
1322
  });
1176
1323
  logger?.info('[NobleBLE] Force reconnect successful');
1177
1324
  connectedDevices.set(deviceId, peripheral);
@@ -1213,12 +1360,7 @@ async function freshScanAndDiscover(
1213
1360
  await new Promise<void>((resolve, reject) => {
1214
1361
  freshPeripheral.connect((error: Error | undefined) => {
1215
1362
  if (error) {
1216
- reject(
1217
- ERRORS.TypedError(
1218
- HardwareErrorCode.BleConnectedError,
1219
- `Fresh peripheral connection failed: ${error.message}`
1220
- )
1221
- );
1363
+ reject(createNobleBleConnectionError(error, 'Fresh peripheral connection failed: '));
1222
1364
  } else {
1223
1365
  connectedDevices.set(deviceId, freshPeripheral);
1224
1366
  resolve();
@@ -1248,12 +1390,14 @@ async function discoverServicesAndCharacteristicsWithRetry(
1248
1390
  deviceId,
1249
1391
  peripheralState: peripheral.state,
1250
1392
  attempt: attemptNumber,
1251
- maxRetries: 5,
1393
+ maxRetries: SERVICE_DISCOVERY_MAX_ATTEMPTS,
1252
1394
  targetUUIDs: ONEKEY_SERVICE_UUIDS,
1253
1395
  });
1254
1396
 
1255
1397
  if (attemptNumber > 1) {
1256
- logger?.info(`[NobleBLE] Service discovery retry attempt ${attemptNumber}/5`);
1398
+ logger?.info(
1399
+ `[NobleBLE] Service discovery retry attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS}`
1400
+ );
1257
1401
  }
1258
1402
 
1259
1403
  // Verify connection state before attempting service discovery
@@ -1267,20 +1411,32 @@ async function discoverServicesAndCharacteristicsWithRetry(
1267
1411
  try {
1268
1412
  return await discoverServicesAndCharacteristics(peripheral);
1269
1413
  } catch (error) {
1270
- logger?.error(`[NobleBLE] No services found (attempt ${attemptNumber}/5)`);
1414
+ logger?.error(
1415
+ `[NobleBLE] No services found (attempt ${attemptNumber}/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1416
+ );
1271
1417
 
1272
- if (attemptNumber < 5) {
1273
- logger?.error(`[NobleBLE] Will retry service discovery (attempt ${attemptNumber + 1}/5)`);
1418
+ if (attemptNumber < SERVICE_DISCOVERY_MAX_ATTEMPTS) {
1419
+ logger?.error(
1420
+ `[NobleBLE] Will retry service discovery (attempt ${
1421
+ attemptNumber + 1
1422
+ }/${SERVICE_DISCOVERY_MAX_ATTEMPTS})`
1423
+ );
1274
1424
  }
1275
1425
 
1276
1426
  throw error; // p-retry will handle the retry logic
1277
1427
  }
1278
1428
  },
1279
1429
  {
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
1430
+ // One retry, not four. An empty result here is the signature of a device
1431
+ // whose stack no longer serves its application layer, and retrying the
1432
+ // same link has never recovered it — every observed run failed all the
1433
+ // way through. The recovery is the teardown and cold reconnect the caller
1434
+ // performs afterwards, so reaching it sooner is what shortens the wait.
1435
+ // The single retry stays for a genuinely transient miss.
1436
+ retries: SERVICE_DISCOVERY_MAX_ATTEMPTS - 1,
1437
+ factor: 1.5,
1438
+ minTimeout: 500,
1439
+ maxTimeout: 3000,
1284
1440
  onFailedAttempt: error => {
1285
1441
  // This runs after each failed attempt
1286
1442
  logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
@@ -1305,26 +1461,48 @@ async function setupConnectionAndDiscoverServices(
1305
1461
  deviceId: string,
1306
1462
  webContents: WebContents
1307
1463
  ): Promise<CharacteristicPair> {
1308
- setupDisconnectListener(peripheral, deviceId, webContents);
1309
-
1464
+ // Reset the link before any protocol traffic reaches the device. 6.5.0 did
1465
+ // this on every cold setup and never produced a protocol-deaf Classic 1S
1466
+ // (control log 2026-08-20: 16/16 links answered within ~1s, including after
1467
+ // a 15-minute idle). Demoting it to a discovery-failure fallback removed the
1468
+ // reset entirely for this device, because discovery always succeeds: the link
1469
+ // comes up, GATT resolves from cache, and the device then answers nothing on
1470
+ // a session it no longer serves. Costs ~1.3s per cold setup; a kept-alive
1471
+ // link with an intact subscription returns before reaching here, so reuse
1472
+ // inside a workflow is unaffected.
1310
1473
  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
- });
1474
+ await forceReconnectPeripheral(peripheral, deviceId);
1475
+ } catch (resetError) {
1476
+ if (isBleStaleBondHardwareError(resetError)) {
1477
+ throw resetError;
1478
+ }
1479
+ // A failed reset must not abort the attempt: discovery on the existing
1480
+ // link, then the fresh-scan fallback, still have a chance to recover.
1481
+ logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
1317
1482
  }
1318
-
1319
- await forceReconnectPeripheral(peripheral, deviceId);
1320
1483
  setupDisconnectListener(peripheral, deviceId, webContents);
1484
+
1321
1485
  try {
1322
1486
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1323
- } catch (reconnectError) {
1324
- logger?.error(
1325
- '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1326
- reconnectError
1327
- );
1487
+ } catch (discoveryError) {
1488
+ // A connected peripheral does not advertise, so the fresh scan below would
1489
+ // only run out its timeout. Drop the link first, then rescan: the cold
1490
+ // reconnect is what recovers a device whose stack stopped serving its
1491
+ // application layer. Recover here rather than throwing to the caller —
1492
+ // one acquire pays ~2s for the rescan, while a thrown error costs the
1493
+ // renderer a full retry round trip.
1494
+ if (peripheral.state === 'connected') {
1495
+ logger?.error(
1496
+ '[NobleBLE] Service discovery failed on a live link, dropping it before the fresh scan',
1497
+ discoveryError
1498
+ );
1499
+ await disconnectDevice(deviceId).catch(() => undefined);
1500
+ } else {
1501
+ logger?.error(
1502
+ '[NobleBLE] Service discovery failed, attempting fresh scan...',
1503
+ discoveryError
1504
+ );
1505
+ }
1328
1506
  return freshScanAndDiscover(deviceId, webContents);
1329
1507
  }
1330
1508
  }
@@ -1413,10 +1591,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1413
1591
  // enumerate clears the discovery map; a kept-alive link outlives it.
1414
1592
  let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1415
1593
 
1416
- // If device not discovered, try a targeted scan for this specific device
1417
1594
  if (!peripheral) {
1418
- logger?.info('[NobleBLE] Device not discovered, attempting targeted scan for:', deviceId);
1419
-
1420
1595
  // Initialize Noble if not already done
1421
1596
  if (!noble) {
1422
1597
  await initializeNoble();
@@ -1426,27 +1601,46 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1426
1601
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1427
1602
  }
1428
1603
 
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
- }
1604
+ // A live advertisement proves the device holds no link and will open a
1605
+ // fresh session; connecting by id resolves through the OS cache instead
1606
+ // and can hand back a session the device no longer serves (see
1607
+ // setupConnectionAndDiscoverServices). Scanning is also the faster of the
1608
+ // two when the device does advertise: ~96ms median against ~784ms for
1609
+ // connect-by-id across the field logs. Pro2/Neo keep connect-by-id first
1610
+ // because they are unaffected and may advertise under an unmatchable Find
1611
+ // My name.
1612
+ const byIdFirst = shouldConnectByIdFirst(deviceId);
1613
+ logger?.info('[NobleBLE] Resolving device for cold connect', {
1614
+ deviceId,
1615
+ name: resolveBleName(deviceId) ?? 'unknown',
1616
+ strategy: byIdFirst ? 'connect-by-id-first' : 'scan-first',
1617
+ });
1435
1618
 
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
- );
1619
+ const scanForPeripheral = async () => {
1620
+ try {
1621
+ return (await performTargetedScan(deviceId, BLE_COLD_CONNECT_SCAN_TIMEOUT_MS)) ?? undefined;
1622
+ } catch (scanError) {
1623
+ logger?.info('[NobleBLE] Targeted scan failed', {
1624
+ deviceId,
1625
+ error: scanError instanceof Error ? scanError.message : String(scanError),
1626
+ });
1627
+ return undefined;
1628
+ }
1629
+ };
1630
+
1631
+ const connectById = async () => {
1632
+ const found = await tryDirectConnectById(deviceId);
1633
+ if (found) {
1634
+ discoveredDevices.set(deviceId, found);
1445
1635
  }
1446
- peripheral = foundPeripheral;
1447
- } catch (error) {
1448
- logger?.error('[NobleBLE] Targeted scan failed:', error);
1449
- throw error;
1636
+ return found;
1637
+ };
1638
+
1639
+ peripheral = byIdFirst ? await connectById() : await scanForPeripheral();
1640
+ if (!peripheral) {
1641
+ // The preferred route came up empty: not advertising (held elsewhere, or
1642
+ // silent), or not reachable by id. Try the other one before giving up.
1643
+ peripheral = byIdFirst ? await scanForPeripheral() : await connectById();
1450
1644
  }
1451
1645
  }
1452
1646
 
@@ -1455,6 +1649,10 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1455
1649
  throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
1456
1650
  }
1457
1651
 
1652
+ // Also covers the connect-by-id route, whose simulated discovery carries no
1653
+ // service UUIDs and therefore never reaches handleDeviceDiscovered.
1654
+ rememberBleName(deviceId, peripheral.advertisement?.localName);
1655
+
1458
1656
  logger?.info('[NobleBLE] Connecting to device:', deviceId);
1459
1657
 
1460
1658
  // Check if device is already connected
@@ -1530,33 +1728,12 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1530
1728
  }
1531
1729
 
1532
1730
  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
1731
  // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1540
1732
  const connectedPeripheral = peripheral as Peripheral;
1541
1733
  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
1734
  if (error) {
1558
1735
  logger?.error('[NobleBLE] Connection failed:', error);
1559
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1736
+ reject(createNobleBleConnectionError(error));
1560
1737
  return;
1561
1738
  }
1562
1739
 
@@ -1575,9 +1752,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1575
1752
  resolve();
1576
1753
  } catch (setupError) {
1577
1754
  logger?.error('[NobleBLE] Connection setup failed:', setupError);
1578
- connectedPeripheral.disconnect(() => {
1579
- reject(setupError);
1580
- });
1755
+ // Never reject from inside a raw disconnect callback: noble only fires
1756
+ // it on a real 'disconnect' event, so a peripheral that is already
1757
+ // down leaves this promise — and the renderer acquire awaiting it —
1758
+ // pending forever. disconnectDevice always settles (it no-ops on an
1759
+ // unknown peripheral and caps the confirm wait).
1760
+ disconnectDevice(deviceId)
1761
+ .catch(() => undefined)
1762
+ .then(() => reject(setupError));
1581
1763
  }
1582
1764
  });
1583
1765
  });
@@ -1597,11 +1779,18 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1597
1779
  }
1598
1780
 
1599
1781
  await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1600
- timeoutMs: BLE_CLEANUP_TIMEOUT,
1782
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1601
1783
  timeoutBehavior: 'resolve',
1602
1784
  });
1785
+ if (peripheral.state === 'connected') {
1786
+ logger?.warn('[NobleBLE] Physical disconnect did not confirm in time', { deviceId });
1787
+ }
1603
1788
  cleanupDevice(deviceId, undefined, {
1604
1789
  cleanupConnection: true,
1790
+ // macOS returns zero GATT services when a previously-disconnected
1791
+ // peripheral object is reconnected; drop the discovery cache so the next
1792
+ // connect resolves a fresh peripheral (direct connect by id, or scan).
1793
+ cleanupDiscoveredCache: true,
1605
1794
  sendDisconnectEvent: false,
1606
1795
  cancelOperations: true,
1607
1796
  reason: 'manual-disconnect',
@@ -1727,7 +1916,7 @@ async function subscribeNotifications(
1727
1916
  timeoutBehavior: 'resolve',
1728
1917
  });
1729
1918
  await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1730
- timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1919
+ timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
1731
1920
  timeoutBehavior: 'reject',
1732
1921
  });
1733
1922
 
@@ -1742,9 +1931,14 @@ async function subscribeNotifications(
1742
1931
  });
1743
1932
  }
1744
1933
 
1934
+ const subscribeStartedAt = Date.now();
1745
1935
  try {
1746
1936
  await rebuildAppSubscription(deviceId, notifyCharacteristic);
1747
1937
  subscribedDevices.set(deviceId, true);
1938
+ logger?.info('[NobleBLE] Notification subscription active', {
1939
+ deviceId,
1940
+ ms: Date.now() - subscribeStartedAt,
1941
+ });
1748
1942
  } finally {
1749
1943
  // 🔒 CRITICAL: Always clear operation state (even on error)
1750
1944
  subscriptionOperations.set(deviceId, 'idle');
@@ -1765,7 +1959,13 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1765
1959
  // Electron throws on duplicate channels and setup re-runs on soft restart.
1766
1960
  const handle: IpcMain['handle'] = (channel, listener) => {
1767
1961
  ipcMain.removeHandler(channel);
1768
- ipcMain.handle(channel, listener);
1962
+ ipcMain.handle(channel, async (...args) => {
1963
+ try {
1964
+ return await Promise.resolve(listener(...args));
1965
+ } catch (error) {
1966
+ return createNobleBleIpcErrorResponse(error);
1967
+ }
1968
+ });
1769
1969
  };
1770
1970
 
1771
1971
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
@@ -1927,10 +2127,12 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1927
2127
  }
1928
2128
 
1929
2129
  await stopScanning().catch(() => undefined);
1930
- if (noble && persistentStateListener) {
1931
- noble.removeListener('stateChange', persistentStateListener);
1932
- persistentStateListener = null;
1933
- }
2130
+ // persistentStateListener is process-lifetime, NOT per-window: this
2131
+ // destroy handler also fires on a renderer soft restart, and removing
2132
+ // the listener here permanently killed poweredOff/poweredOn
2133
+ // reconciliation for the rest of the process (nothing re-registers it:
2134
+ // initializeNoble early-returns once noble is loaded). The null-guard
2135
+ // in setupPersistentStateListener keeps it deduped to one instance.
1934
2136
  cleanupNobleListeners();
1935
2137
  discoveredDevices.clear();
1936
2138
  safeLog(logger, 'info', 'Noble BLE cleanup completed');