@onekeyfe/hd-transport-electron 1.2.0-alpha.8 → 1.2.0-alpha.80

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.
@@ -9,17 +9,29 @@ import {
9
9
  EOneKeyBleMessageKeys,
10
10
  ERRORS,
11
11
  HardwareErrorCode,
12
+ ONEKEY_NOTIFY_CHARACTERISTIC_UUID,
12
13
  ONEKEY_SERVICE_UUID,
13
- isOnekeyDevice,
14
+ ONEKEY_WRITE_CHARACTERISTIC_UUID,
15
+ createKnownBleUuidAliases,
16
+ hasOnekeyCommunicationService,
17
+ isOnekeyBluetoothDevice,
18
+ isPro2FindMyAdvertisementName,
19
+ matchesKnownBleUuid,
14
20
  wait,
15
21
  } from '@onekeyfe/hd-shared';
16
22
  import pRetry from 'p-retry';
17
23
 
24
+ import { resolveBlePacketCapacity } from './ble-packet-capacity';
18
25
  import { safeLog } from './types/noble-extended';
19
- import { softRefreshSubscription } from './ble-ops';
26
+ import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
27
+ import {
28
+ NOBLE_BLE_CONNECTION_TIMEOUT_MS,
29
+ NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
30
+ } from './noble-ble-timeouts';
20
31
 
21
- import type { IpcMainInvokeEvent, WebContents } from 'electron';
32
+ import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
22
33
  import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
34
+ import type { NobleBleWriteOptions } from './types/desktop-api';
23
35
  import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
24
36
 
25
37
  // Noble will be dynamically imported to avoid bundling issues
@@ -39,6 +51,7 @@ const bluetoothState: {
39
51
 
40
52
  // Global persistent state listener for app layer
41
53
  let persistentStateListener: ((state: string) => void) | null = null;
54
+ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null;
42
55
 
43
56
  // Device cache and connection state
44
57
  const discoveredDevices = new Map<string, Peripheral>();
@@ -50,6 +63,14 @@ const subscribedDevices = new Map<string, boolean>(); // Track subscription stat
50
63
 
51
64
  // 🔒 Subscription operation state tracking to prevent race conditions
52
65
  const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' | 'idle'>();
66
+ const deviceDisconnectListeners = new Map<
67
+ string,
68
+ { peripheral: Peripheral; listener: () => void }
69
+ >();
70
+ const deviceMtuListeners = new Map<
71
+ string,
72
+ { peripheral: Peripheral; listener: (mtu: number) => void }
73
+ >();
53
74
 
54
75
  // Windows-only response watchdog state moved to utils/windows-ble-recovery
55
76
 
@@ -59,57 +80,55 @@ const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' |
59
80
 
60
81
  // Service UUIDs to scan for - using constants from hd-shared
61
82
  const ONEKEY_SERVICE_UUIDS = [ONEKEY_SERVICE_UUID];
62
- const PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS = new Set(['fffd']);
63
-
64
- // Pre-normalized characteristic identifiers for fast comparison
65
- const NORMALIZED_WRITE_UUID = '0002';
66
- const NORMALIZED_NOTIFY_UUID = '0003';
83
+ const ONEKEY_SERVICE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_SERVICE_UUID);
84
+ const ONEKEY_WRITE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_WRITE_CHARACTERISTIC_UUID);
85
+ const ONEKEY_NOTIFY_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_NOTIFY_CHARACTERISTIC_UUID);
67
86
 
68
87
  // Timeout and interval constants
69
88
  const BLUETOOTH_INIT_TIMEOUT = 10000; // 10 seconds for Bluetooth initialization
70
- const DEVICE_SCAN_TIMEOUT = 8000; // 8 seconds for device scanning (Pro2 has longer advertising interval)
71
- const FAST_SCAN_TIMEOUT = 8000; // 8 seconds for targeted scanning (Pro2 has longer advertising interval)
89
+ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
72
90
  const DEVICE_CHECK_INTERVAL = 500; // 500ms interval for periodic device checks
73
- const CONNECTION_TIMEOUT = 8000; // 8 seconds for device connection (BLE reconnect after release can be slow)
74
91
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
92
+ const BLE_CLEANUP_TIMEOUT = 250;
93
+ // Renderer release is logical only; this timer physically frees the device.
94
+ const BLE_IDLE_DISCONNECT_MS = 3 * 60_000;
95
+ // Ceiling while a call is in flight: no outstanding write, but not forever.
96
+ const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
75
97
 
76
98
  // Write-related constants
77
- const BLE_PACKET_SIZE = 192;
78
- const UNIFIED_WRITE_DELAY = 5;
99
+ const BLE_PACKET_SIZE_FALLBACK = 192;
100
+ const BLE_PACKET_SIZE_MAXIMUM = 244;
101
+ const DEFAULT_WRITE_PACING_DELAY_MS = 5;
79
102
  const RETRY_CONFIG = { MAX_ATTEMPTS: 15, WRITE_TIMEOUT: 2000 } as const;
80
103
  const IS_WINDOWS = process.platform === 'win32';
81
104
  const ABORTABLE_WRITE_ERROR_PATTERNS = [
82
105
  /status:\s*3/i, // Windows pairing cancelled / GATT write failed
83
106
  ];
84
107
 
85
- function getBleUuidKey(uuid?: string | null) {
86
- const normalized = (uuid ?? '').replace(/-/g, '').toLowerCase();
87
- return normalized.length >= 8 ? normalized.substring(4, 8) : normalized;
108
+ export function resolveNobleBleWritePacingDelay(options?: NobleBleWriteOptions) {
109
+ return typeof options?.pacingDelayMs === 'number' && Number.isFinite(options.pacingDelayMs)
110
+ ? Math.min(Math.max(Math.floor(options.pacingDelayMs), 0), 1000)
111
+ : DEFAULT_WRITE_PACING_DELAY_MS;
88
112
  }
89
113
 
90
- const NORMALIZED_ONEKEY_SERVICE_UUIDS = new Set([
91
- ...ONEKEY_SERVICE_UUIDS.map(uuid => getBleUuidKey(uuid)),
92
- '0001',
93
- ]);
94
-
95
- function isGenericBleService(uuid?: string | null) {
96
- return ['1800', '1801', '180a', '180f'].includes(getBleUuidKey(uuid));
97
- }
114
+ function isOneKeyPeripheral(peripheral: Peripheral) {
115
+ const serviceUuids = peripheral.advertisement?.serviceUuids;
116
+ const localName = peripheral.advertisement?.localName;
98
117
 
99
- function hasOneKeyAdvertisementService(peripheral: Peripheral) {
100
- const serviceUuids = peripheral.advertisement?.serviceUuids ?? [];
101
- return serviceUuids.some(uuid => {
102
- const uuidKey = getBleUuidKey(uuid);
103
- return (
104
- NORMALIZED_ONEKEY_SERVICE_UUIDS.has(uuidKey) ||
105
- PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(uuidKey)
106
- );
107
- });
108
- }
118
+ // Noble localName is the current advertisement name, so reject the Pro2
119
+ // Find My endpoint before the communication-service fast path accepts it.
120
+ if (!localName?.trim() || isPro2FindMyAdvertisementName(localName)) {
121
+ return false;
122
+ }
109
123
 
110
- function isOneKeyPeripheral(peripheral: Peripheral) {
111
- const deviceName = peripheral.advertisement?.localName || null;
112
- return isOnekeyDevice(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
124
+ return (
125
+ hasOnekeyCommunicationService(serviceUuids) &&
126
+ isOnekeyBluetoothDevice({
127
+ id: peripheral.id,
128
+ localName,
129
+ serviceUuids,
130
+ })
131
+ );
113
132
  }
114
133
 
115
134
  /**
@@ -117,12 +136,6 @@ function isOneKeyPeripheral(peripheral: Peripheral) {
117
136
  * renderer-side transport. Packet reassembly is handled by ElectronBleTransport.
118
137
  */
119
138
  function emitRawNotification(deviceId: string, data: Buffer): void {
120
- logger?.info('[NobleBLE] Raw notification', {
121
- deviceId,
122
- dataLength: data.length,
123
- firstBytes: data.subarray(0, 8).toString('hex'),
124
- });
125
-
126
139
  const appCb = notificationCallbacks.get(deviceId);
127
140
  if (appCb) appCb(data.toString('hex'));
128
141
  }
@@ -296,9 +309,12 @@ async function initializeNoble(): Promise<void> {
296
309
  });
297
310
 
298
311
  // Set up device discovery
299
- noble.on('discover', (peripheral: Peripheral) => {
300
- handleDeviceDiscovered(peripheral);
301
- });
312
+ if (!persistentDiscoverListener) {
313
+ persistentDiscoverListener = (peripheral: Peripheral) => {
314
+ handleDeviceDiscovered(peripheral);
315
+ };
316
+ noble.on('discover', persistentDiscoverListener);
317
+ }
302
318
 
303
319
  logger?.info('[NobleBLE] Noble initialized successfully');
304
320
  } catch (error) {
@@ -329,6 +345,76 @@ interface DeviceCleanupOptions {
329
345
  reason?: string;
330
346
  }
331
347
 
348
+ // One timer per device, in the main process so a reload cannot orphan a link.
349
+ const idleDisconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
350
+
351
+ // A fired timer cannot be cancelled; connect awaits its disconnect or the fast
352
+ // path hands back a link torn down a moment later (BleTimeoutError 713).
353
+ const idleDisconnectInFlight = new Map<string, Promise<void>>();
354
+
355
+ function clearIdleDisconnect(deviceId: string): void {
356
+ const timer = idleDisconnectTimers.get(deviceId);
357
+ if (timer) {
358
+ clearTimeout(timer);
359
+ idleDisconnectTimers.delete(deviceId);
360
+ }
361
+ }
362
+
363
+ /** Settle any keep-alive disconnect already running for this device. */
364
+ async function awaitIdleDisconnect(deviceId: string): Promise<void> {
365
+ const pending = idleDisconnectInFlight.get(deviceId);
366
+ if (pending) await pending.catch(() => undefined);
367
+ }
368
+
369
+ function broadcastToAllWebContents(channel: string, payload: unknown): void {
370
+ try {
371
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
372
+ const { webContents: electronWebContents } = require('electron') as {
373
+ webContents: { getAllWebContents(): WebContents[] };
374
+ };
375
+ for (const wc of electronWebContents.getAllWebContents()) {
376
+ if (!wc.isDestroyed()) wc.send(channel, payload);
377
+ }
378
+ } catch (error) {
379
+ logger?.error('[NobleBLE] broadcast failed:', { channel, error: String(error) });
380
+ }
381
+ }
382
+
383
+ function armIdleDisconnect(
384
+ deviceId: string,
385
+ ms: number = BLE_IDLE_DISCONNECT_MS,
386
+ reason: 'idle' | 'busy-backstop' = 'idle'
387
+ ): void {
388
+ clearIdleDisconnect(deviceId);
389
+ idleDisconnectTimers.set(
390
+ deviceId,
391
+ setTimeout(() => {
392
+ idleDisconnectTimers.delete(deviceId);
393
+ if (!connectedDevices.has(deviceId)) return;
394
+ logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
395
+ const peripheral = connectedDevices.get(deviceId);
396
+ const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
397
+ const pending = disconnectDevice(deviceId)
398
+ .then(() => {
399
+ // A call is still in flight here; it must reject, not hang.
400
+ broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
401
+ id: deviceId,
402
+ name: deviceName,
403
+ });
404
+ })
405
+ .catch(error => {
406
+ logger?.error('[NobleBLE] Keep-alive disconnect failed', { deviceId, error });
407
+ })
408
+ .finally(() => {
409
+ if (idleDisconnectInFlight.get(deviceId) === pending) {
410
+ idleDisconnectInFlight.delete(deviceId);
411
+ }
412
+ });
413
+ idleDisconnectInFlight.set(deviceId, pending);
414
+ }, ms)
415
+ );
416
+ }
417
+
332
418
  /**
333
419
  * Unified device cleanup function - single entry point for all cleanup operations
334
420
  */
@@ -354,12 +440,24 @@ function cleanupDevice(
354
440
  cancelOperations,
355
441
  });
356
442
 
443
+ clearIdleDisconnect(deviceId);
444
+
357
445
  // Get device info before cleanup
358
446
  const peripheral = connectedDevices.get(deviceId);
359
447
  const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
360
448
 
361
449
  // 1. Clean up connection state
362
450
  if (cleanupConnection) {
451
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
452
+ if (disconnectEntry) {
453
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
454
+ deviceDisconnectListeners.delete(deviceId);
455
+ }
456
+ const mtuEntry = deviceMtuListeners.get(deviceId);
457
+ if (mtuEntry) {
458
+ mtuEntry.peripheral.removeListener('mtu', mtuEntry.listener);
459
+ deviceMtuListeners.delete(deviceId);
460
+ }
363
461
  connectedDevices.delete(deviceId);
364
462
  deviceCharacteristics.delete(deviceId);
365
463
  notificationCallbacks.delete(deviceId);
@@ -373,9 +471,11 @@ function cleanupDevice(
373
471
  discoveredDevices.delete(deviceId);
374
472
  }
375
473
 
376
- // 3. Send disconnect event (if needed)
377
- if (sendDisconnectEvent && webContents) {
378
- webContents.send(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
474
+ // 3. Send disconnect event (if needed). Broadcast, not the captured
475
+ // webContents: a kept-alive link outlives a renderer soft restart, and the
476
+ // new renderer still needs to hear the device dropped (same as the idle path).
477
+ if (sendDisconnectEvent) {
478
+ broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
379
479
  id: deviceId,
380
480
  name: deviceName,
381
481
  });
@@ -409,18 +509,45 @@ function setupDisconnectListener(
409
509
  deviceId: string,
410
510
  webContents: WebContents
411
511
  ): void {
412
- // Remove any existing disconnect listeners to avoid duplicates
413
- peripheral.removeAllListeners('disconnect');
512
+ const existing = deviceDisconnectListeners.get(deviceId);
513
+ if (existing) {
514
+ existing.peripheral.removeListener('disconnect', existing.listener);
515
+ }
414
516
 
415
- // Set up new disconnect listener
416
- peripheral.on('disconnect', () => {
517
+ const listener = () => {
417
518
  handleDeviceDisconnect(deviceId, webContents);
418
- });
519
+ };
520
+ deviceDisconnectListeners.set(deviceId, { peripheral, listener });
521
+ peripheral.on('disconnect', listener);
522
+ setupMtuListener(peripheral, deviceId, webContents);
523
+ }
524
+
525
+ function setupMtuListener(
526
+ peripheral: Peripheral,
527
+ deviceId: string,
528
+ webContents: WebContents
529
+ ): void {
530
+ const existing = deviceMtuListeners.get(deviceId);
531
+ if (existing) {
532
+ existing.peripheral.removeListener('mtu', existing.listener);
533
+ }
534
+
535
+ const listener = (mtu: number) => {
536
+ if (!Number.isFinite(mtu) || mtu <= 0) return;
537
+ // A kept-alive link can outlive the renderer this closure captured.
538
+ if (webContents.isDestroyed()) return;
539
+ webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
540
+ id: deviceId,
541
+ mtu,
542
+ });
543
+ };
544
+ deviceMtuListeners.set(deviceId, { peripheral, listener });
545
+ peripheral.on('mtu', listener);
419
546
  }
420
547
 
421
548
  // ===== Write helpers (inline) =====
422
549
 
423
- async function writeCharacteristicWithAck(
550
+ async function writeCharacteristicWithoutResponse(
424
551
  deviceId: string,
425
552
  writeCharacteristic: Characteristic,
426
553
  buffer: Buffer
@@ -468,7 +595,7 @@ async function attemptWindowsWriteUntilPaired(
468
595
  }
469
596
 
470
597
  try {
471
- await writeCharacteristicWithAck(deviceId, latestWrite, payload);
598
+ await writeCharacteristicWithoutResponse(deviceId, latestWrite, payload);
472
599
  } catch (e) {
473
600
  const errorMessage = e instanceof Error ? e.message : String(e);
474
601
  logger?.error('[BLE-Write] Windows write error', {
@@ -540,7 +667,11 @@ async function attemptWindowsWriteUntilPaired(
540
667
  );
541
668
  }
542
669
 
543
- async function transmitHexDataToDevice(deviceId: string, hexData: string): Promise<void> {
670
+ async function transmitHexDataToDevice(
671
+ deviceId: string,
672
+ hexData: string,
673
+ options?: NobleBleWriteOptions
674
+ ): Promise<void> {
544
675
  const characteristics = deviceCharacteristics.get(deviceId);
545
676
  const peripheral = connectedDevices.get(deviceId);
546
677
  if (!peripheral || !characteristics) {
@@ -549,15 +680,17 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
549
680
  `Device ${deviceId} not connected or characteristics not available`
550
681
  );
551
682
  }
683
+ // Request outstanding: swap the idle clock for the busy backstop.
684
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
552
685
 
553
686
  const toBuffer = Buffer.from(hexData, 'hex');
554
- logger?.info('[NobleBLE] Writing data:', {
555
- deviceId,
556
- dataLength: toBuffer.length,
557
- firstBytes: toBuffer.subarray(0, 8).toString('hex'),
558
- });
559
-
560
687
  const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
688
+ const packetCapacity = resolveBlePacketCapacity(
689
+ peripheral.mtu,
690
+ BLE_PACKET_SIZE_MAXIMUM,
691
+ BLE_PACKET_SIZE_FALLBACK
692
+ );
693
+ const pacingDelayMs = resolveNobleBleWritePacingDelay(options);
561
694
 
562
695
  if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
563
696
  // macOS / Linux or already paired on Windows: direct write
@@ -568,14 +701,14 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
568
701
  `Write characteristic not available for ${deviceId}`
569
702
  );
570
703
  }
571
- if (toBuffer.length <= BLE_PACKET_SIZE) {
572
- await wait(UNIFIED_WRITE_DELAY);
573
- await writeCharacteristicWithAck(deviceId, writeCharacteristic, toBuffer);
704
+ if (toBuffer.length <= packetCapacity) {
705
+ if (pacingDelayMs > 0) await wait(pacingDelayMs);
706
+ await writeCharacteristicWithoutResponse(deviceId, writeCharacteristic, toBuffer);
574
707
  return;
575
708
  }
576
709
  // chunked
577
710
  for (let offset = 0; offset < toBuffer.length; ) {
578
- const chunkSize = Math.min(BLE_PACKET_SIZE, toBuffer.length - offset);
711
+ const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
579
712
  const chunk = toBuffer.subarray(offset, offset + chunkSize);
580
713
  offset += chunkSize;
581
714
  const latest = doGetWriteCharacteristic();
@@ -585,23 +718,23 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
585
718
  `Write characteristic not available for ${deviceId}`
586
719
  );
587
720
  }
588
- await writeCharacteristicWithAck(deviceId, latest, chunk);
589
- if (offset < toBuffer.length) {
590
- await wait(UNIFIED_WRITE_DELAY);
721
+ await writeCharacteristicWithoutResponse(deviceId, latest, chunk);
722
+ if (offset < toBuffer.length && pacingDelayMs > 0) {
723
+ await wait(pacingDelayMs);
591
724
  }
592
725
  }
593
726
  return;
594
727
  }
595
728
 
596
729
  // Windows unpaired path: use loop
597
- if (toBuffer.length <= BLE_PACKET_SIZE) {
598
- await wait(UNIFIED_WRITE_DELAY);
730
+ if (toBuffer.length <= packetCapacity) {
731
+ if (pacingDelayMs > 0) await wait(pacingDelayMs);
599
732
  await attemptWindowsWriteUntilPaired(deviceId, doGetWriteCharacteristic, toBuffer, 'single');
600
733
  return;
601
734
  }
602
735
  // chunked loop
603
736
  for (let offset = 0, idx = 0; offset < toBuffer.length; idx++) {
604
- const chunkSize = Math.min(BLE_PACKET_SIZE, toBuffer.length - offset);
737
+ const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
605
738
  const chunk = toBuffer.subarray(offset, offset + chunkSize);
606
739
  offset += chunkSize;
607
740
  await attemptWindowsWriteUntilPaired(
@@ -610,17 +743,14 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
610
743
  chunk,
611
744
  `chunk-${idx + 1}`
612
745
  );
613
- if (offset < toBuffer.length) {
614
- await wait(UNIFIED_WRITE_DELAY);
746
+ if (offset < toBuffer.length && pacingDelayMs > 0) {
747
+ await wait(pacingDelayMs);
615
748
  }
616
749
  }
617
750
  }
618
751
 
619
752
  // Handle discovered device (for general enumeration only)
620
753
  function handleDeviceDiscovered(peripheral: Peripheral): void {
621
- const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
622
- const serviceUuids = peripheral.advertisement?.serviceUuids || [];
623
-
624
754
  // Only process OneKey candidates for general discovery. Avoid logging every
625
755
  // ambient BLE peripheral; it makes Pro2 debugging hard to read.
626
756
  if (!isOneKeyPeripheral(peripheral)) {
@@ -630,10 +760,10 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
630
760
  const isNewDevice = !discoveredDevices.has(peripheral.id);
631
761
  discoveredDevices.set(peripheral.id, peripheral);
632
762
  if (isNewDevice) {
633
- logger?.info('[NobleBLE] Discovered OneKey BLE device:', {
634
- name: deviceName,
635
- id: peripheral.id,
636
- serviceUUIDs: serviceUuids,
763
+ logger?.debug('[NobleBLE] OneKey BLE device discovered', {
764
+ deviceId: peripheral.id,
765
+ name: peripheral.advertisement?.localName || 'Unknown Device',
766
+ serviceUUIDs: peripheral.advertisement?.serviceUuids || [],
637
767
  });
638
768
  }
639
769
  }
@@ -643,19 +773,24 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
643
773
  function ensureDiscoverListener(): void {
644
774
  if (!noble) return;
645
775
 
646
- // Check if discover listener exists by checking listener count
647
- const listenerCount = (noble as any).listenerCount('discover');
648
-
649
- if (listenerCount === 0) {
776
+ if (!persistentDiscoverListener) {
650
777
  logger?.info('[NobleBLE] Discover listener missing, re-adding it');
651
- noble.on('discover', (peripheral: Peripheral) => {
778
+ persistentDiscoverListener = (peripheral: Peripheral) => {
652
779
  handleDeviceDiscovered(peripheral);
653
- });
780
+ };
781
+ noble.on('discover', persistentDiscoverListener);
654
782
  } else {
655
- logger?.debug('[NobleBLE] Discover listener already exists, count:', listenerCount);
783
+ logger?.debug('[NobleBLE] Discover listener already registered');
656
784
  }
657
785
  }
658
786
 
787
+ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
788
+ await runBleCallbackOperation(callback => nobleInstance.stopScanning(() => callback()), {
789
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
790
+ timeoutBehavior: 'resolve',
791
+ });
792
+ }
793
+
659
794
  // Perform targeted scan for a specific device ID
660
795
  // Uses self-contained local listener pattern - no global state needed
661
796
  async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
@@ -669,28 +804,41 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
669
804
  logger?.info('[NobleBLE] Starting targeted scan for device:', targetDeviceId);
670
805
 
671
806
  return new Promise((resolve, reject) => {
807
+ let settled = false;
808
+
809
+ const finish = async (peripheral: Peripheral | null, error?: Error) => {
810
+ if (settled) return;
811
+ settled = true;
812
+ if (timeoutId) clearTimeout(timeoutId);
813
+ nobleInstance.removeListener('discover', onDiscover);
814
+ await waitForNobleScanStop(nobleInstance);
815
+
816
+ if (error) {
817
+ logger?.error('[NobleBLE] Failed to start targeted scan:', error);
818
+ reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
819
+ return;
820
+ }
821
+ if (peripheral) {
822
+ discoveredDevices.set(peripheral.id, peripheral);
823
+ }
824
+ resolve(peripheral);
825
+ };
826
+
672
827
  // Local discover listener - only matches target device
673
828
  const onDiscover = (peripheral: Peripheral) => {
674
- if (peripheral.id === targetDeviceId) {
829
+ if (peripheral.id === targetDeviceId && isOneKeyPeripheral(peripheral)) {
675
830
  logger?.info('[NobleBLE] Target device found during targeted scan:', {
676
831
  id: peripheral.id,
677
832
  name: peripheral.advertisement?.localName,
678
833
  });
679
- clearTimeout(timeoutId);
680
- nobleInstance.removeListener('discover', onDiscover);
681
- nobleInstance.stopScanning();
682
- discoveredDevices.set(peripheral.id, peripheral);
683
- resolve(peripheral);
834
+ finish(peripheral).catch(reject);
684
835
  }
685
836
  };
686
837
 
687
- // Timeout handler - must be after onDiscover so it can reference it
688
838
  const timeoutId = setTimeout(() => {
689
- nobleInstance.removeListener('discover', onDiscover);
690
- nobleInstance.stopScanning();
691
839
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
692
- resolve(null);
693
- }, FAST_SCAN_TIMEOUT);
840
+ finish(null).catch(reject);
841
+ }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
694
842
 
695
843
  // Add local listener for this scan
696
844
  nobleInstance.on('discover', onDiscover);
@@ -698,10 +846,7 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
698
846
  // Start scanning — no service UUID filter (Pro2 may use different service UUID)
699
847
  nobleInstance.startScanning([], false, (error?: Error) => {
700
848
  if (error) {
701
- clearTimeout(timeoutId);
702
- nobleInstance.removeListener('discover', onDiscover);
703
- logger?.error('[NobleBLE] Failed to start targeted scan:', error);
704
- reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
849
+ finish(null, error).catch(reject);
705
850
  return;
706
851
  }
707
852
  logger?.info('[NobleBLE] Targeted scan started for device:', targetDeviceId);
@@ -735,34 +880,36 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
735
880
  const devices: DeviceInfo[] = [];
736
881
  let intervalId: ReturnType<typeof setInterval> | undefined;
737
882
 
738
- // Cleanup function: clears both timeout and interval
739
- const cleanup = () => {
883
+ // Cleanup function: clears timers and waits until Noble confirms scanning
884
+ // has stopped. Resolving enumerate before this callback creates a race with
885
+ // an immediately-following connection attempt.
886
+ const cleanup = async () => {
740
887
  clearTimeout(timeoutId);
741
888
  if (intervalId) clearInterval(intervalId);
742
- nobleInstance.stopScanning();
889
+ await waitForNobleScanStop(nobleInstance);
743
890
  };
744
891
 
745
892
  // Collect discovered devices into the devices array
746
- const checkDevices = () => {
747
- discoveredDevices.forEach((peripheral, id) => {
748
- const existingDevice = devices.find(d => d.id === id);
749
- if (!existingDevice) {
750
- const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
751
- devices.push({
752
- commType: 'electron-ble',
753
- id,
754
- name: deviceName,
755
- state: peripheral.state || 'disconnected',
756
- });
757
- }
893
+ const pushDevice = (peripheral: Peripheral, id: string) => {
894
+ if (devices.some(d => d.id === id)) return;
895
+ devices.push({
896
+ commType: 'electron-ble',
897
+ id,
898
+ name: peripheral.advertisement?.localName || 'Unknown Device',
899
+ state: peripheral.state || 'disconnected',
758
900
  });
759
901
  };
902
+ const checkDevices = () => {
903
+ discoveredDevices.forEach(pushDevice);
904
+ // A linked device stops advertising, so the scan never rediscovers it.
905
+ connectedDevices.forEach(pushDevice);
906
+ };
760
907
 
761
908
  // Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
762
- const timeoutId = setTimeout(() => {
909
+ const timeoutId = setTimeout(async () => {
763
910
  // Final collection before resolving — catches devices discovered near the deadline
764
911
  checkDevices();
765
- cleanup();
912
+ await cleanup();
766
913
  logger?.info('[NobleBLE] Scan completed, found devices:', devices.length);
767
914
  resolve(devices);
768
915
  }, DEVICE_SCAN_TIMEOUT);
@@ -770,9 +917,9 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
770
917
  // Start scanning without a service UUID filter so Pro2 advertisements with
771
918
  // short vendor UUIDs can be found, but only OneKey candidates are logged/returned.
772
919
  logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
773
- nobleInstance.startScanning([], false, (error?: Error) => {
920
+ nobleInstance.startScanning([], false, async (error?: Error) => {
774
921
  if (error) {
775
- cleanup();
922
+ await cleanup();
776
923
  logger?.error('[NobleBLE] Failed to start scanning:', error);
777
924
  reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
778
925
  return;
@@ -789,30 +936,21 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
789
936
  // Stop scanning
790
937
  async function stopScanning(): Promise<void> {
791
938
  if (!noble) return;
792
-
793
- return new Promise<void>(resolve => {
794
- if (!noble) {
795
- resolve();
796
- return;
797
- }
798
-
799
- noble.stopScanning(() => {
800
- logger?.info('[NobleBLE] Scanning stopped');
801
- resolve();
802
- });
803
- });
939
+ const nobleInstance = noble;
940
+ await waitForNobleScanStop(nobleInstance);
941
+ logger?.info('[NobleBLE] Scanning stopped');
804
942
  }
805
943
 
806
944
  // Clean up all Noble listeners (for app exit)
807
945
  function cleanupNobleListeners(): void {
808
946
  if (!noble) return;
809
947
 
810
- // Remove all listeners to prevent memory leaks
811
- // Noble uses EventEmitter, need to use removeAllListeners
812
948
  try {
813
- (noble as any).removeAllListeners('discover');
814
- (noble as any).removeAllListeners('stateChange');
815
- logger?.info('[NobleBLE] All Noble listeners cleaned up');
949
+ if (persistentDiscoverListener) {
950
+ noble.removeListener('discover', persistentDiscoverListener);
951
+ persistentDiscoverListener = null;
952
+ }
953
+ logger?.info('[NobleBLE] Owned Noble listeners cleaned up');
816
954
  } catch (error) {
817
955
  logger?.error('[NobleBLE] Failed to clean up some listeners:', error);
818
956
  }
@@ -829,6 +967,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
829
967
  id: peripheral.id,
830
968
  name: deviceName,
831
969
  state: peripheral.state || 'disconnected',
970
+ ...(typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}),
832
971
  };
833
972
  }
834
973
 
@@ -841,6 +980,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
841
980
  id: connectedPeripheral.id,
842
981
  name: deviceName,
843
982
  state: connectedPeripheral.state || 'connected',
983
+ ...(typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}),
844
984
  };
845
985
  }
846
986
 
@@ -910,31 +1050,25 @@ async function discoverServicesAndCharacteristics(
910
1050
  });
911
1051
  });
912
1052
 
913
- // Log all discovered services
914
- logger?.info(
915
- '[NobleBLE] All services:',
916
- services?.map(s => s.uuid)
917
- );
918
-
919
1053
  if (!services || services.length === 0) {
920
1054
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No services found');
921
1055
  }
922
1056
 
1057
+ logger?.debug('[NobleBLE] services discovered', {
1058
+ deviceId: peripheral.id,
1059
+ serviceUUIDs: services.map(service => service.uuid),
1060
+ });
1061
+
923
1062
  // Find OneKey service — Noble may expose 128-bit UUIDs as short UUID keys.
924
- let service = services.find(s => NORMALIZED_ONEKEY_SERVICE_UUIDS.has(getBleUuidKey(s.uuid)));
925
- if (!service) {
926
- logger?.info('[NobleBLE] Known OneKey service UUID not found, trying first vendor service');
927
- service =
928
- services.find(s => PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(getBleUuidKey(s.uuid))) ||
929
- services.find(s => !isGenericBleService(s.uuid)) ||
930
- services[0];
931
- }
1063
+ const service = services.find(s => matchesKnownBleUuid(s.uuid, ONEKEY_SERVICE_UUID_ALIASES));
932
1064
  if (!service) {
933
1065
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
934
1066
  }
935
1067
  const selectedService = service;
936
- logger?.info('[NobleBLE] Using service:', selectedService.uuid);
937
-
1068
+ logger?.debug('[NobleBLE] service selected', {
1069
+ deviceId: peripheral.id,
1070
+ serviceUuid: selectedService.uuid,
1071
+ });
938
1072
  // Step 2: Discover ALL characteristics (no filter)
939
1073
  const characteristics = await new Promise<Characteristic[]>((resolve, reject) => {
940
1074
  selectedService.discoverCharacteristics([], (error, chars) => {
@@ -948,30 +1082,17 @@ async function discoverServicesAndCharacteristics(
948
1082
  });
949
1083
 
950
1084
  // Step 3: Find required characteristics
951
- logger?.info('[NobleBLE] Discovered characteristics:', {
952
- count: characteristics?.length || 0,
953
- uuids: characteristics?.map(c => c.uuid) || [],
954
- });
955
-
956
1085
  let writeCharacteristic: Characteristic | null = null;
957
1086
  let notifyCharacteristic: Characteristic | null = null;
958
1087
 
959
1088
  for (const characteristic of characteristics) {
960
- const uuid = characteristic.uuid.replace(/-/g, '').toLowerCase();
961
- const uuidKey = uuid.length >= 8 ? uuid.substring(4, 8) : uuid;
962
-
963
- if (uuidKey === NORMALIZED_WRITE_UUID) {
1089
+ if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_WRITE_UUID_ALIASES)) {
964
1090
  writeCharacteristic = characteristic;
965
- } else if (uuidKey === NORMALIZED_NOTIFY_UUID) {
1091
+ } else if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_NOTIFY_UUID_ALIASES)) {
966
1092
  notifyCharacteristic = characteristic;
967
1093
  }
968
1094
  }
969
1095
 
970
- logger?.info('[NobleBLE] Characteristic discovery result:', {
971
- writeFound: !!writeCharacteristic,
972
- notifyFound: !!notifyCharacteristic,
973
- });
974
-
975
1096
  if (!writeCharacteristic || !notifyCharacteristic) {
976
1097
  logger?.error(
977
1098
  '[NobleBLE] Missing characteristics - write:',
@@ -985,6 +1106,13 @@ async function discoverServicesAndCharacteristics(
985
1106
  );
986
1107
  }
987
1108
 
1109
+ logger?.debug('[NobleBLE] characteristics selected', {
1110
+ deviceId: peripheral.id,
1111
+ serviceUuid: selectedService.uuid,
1112
+ writeUuid: writeCharacteristic.uuid,
1113
+ notifyUuid: notifyCharacteristic.uuid,
1114
+ });
1115
+
988
1116
  return { write: writeCharacteristic, notify: notifyCharacteristic };
989
1117
  })();
990
1118
 
@@ -1017,29 +1145,23 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1017
1145
 
1018
1146
  // Step 2: Force disconnect if connected
1019
1147
  if (peripheral.state === 'connected') {
1020
- // Remove disconnect listener before intentional disconnect to avoid spurious events
1021
- peripheral.removeAllListeners('disconnect');
1022
- await new Promise<void>(resolve => {
1023
- peripheral.disconnect(() => {
1024
- logger?.info('[NobleBLE] Force disconnect completed');
1025
- resolve();
1026
- });
1027
- });
1148
+ await runBleCallbackOperation(
1149
+ callback =>
1150
+ peripheral.disconnect(() => {
1151
+ logger?.info('[NobleBLE] Force disconnect completed');
1152
+ callback();
1153
+ }),
1154
+ { timeoutMs: BLE_CLEANUP_TIMEOUT, timeoutBehavior: 'resolve' }
1155
+ );
1028
1156
  }
1029
1157
 
1030
1158
  // Step 3: Re-establish connection
1031
- await new Promise<void>((resolve, reject) => {
1032
- peripheral.connect((error: Error | undefined) => {
1033
- if (error) {
1034
- logger?.error('[NobleBLE] Force reconnect failed:', error);
1035
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1036
- } else {
1037
- logger?.info('[NobleBLE] Force reconnect successful');
1038
- connectedDevices.set(deviceId, peripheral);
1039
- resolve();
1040
- }
1041
- });
1159
+ await runBleCallbackOperation(callback => peripheral.connect(callback), {
1160
+ timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1161
+ timeoutBehavior: 'reject',
1042
1162
  });
1163
+ logger?.info('[NobleBLE] Force reconnect successful');
1164
+ connectedDevices.set(deviceId, peripheral);
1043
1165
 
1044
1166
  // Wait for connection to stabilize
1045
1167
  await wait(500);
@@ -1170,20 +1292,100 @@ async function setupConnectionAndDiscoverServices(
1170
1292
  deviceId: string,
1171
1293
  webContents: WebContents
1172
1294
  ): Promise<CharacteristicPair> {
1173
- // Force reconnect to clear GATT cache
1174
- await forceReconnectPeripheral(peripheral, deviceId);
1175
1295
  setupDisconnectListener(peripheral, deviceId, webContents);
1176
1296
 
1177
- // Discover services and characteristics with retry
1178
1297
  try {
1179
1298
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1180
- } catch (error) {
1181
- // Last resort: fresh scan to get new peripheral object
1182
- logger?.error('[NobleBLE] Service discovery failed, attempting fresh scan...', error);
1299
+ } catch (initialError) {
1300
+ logger?.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
1301
+ deviceId,
1302
+ error: initialError,
1303
+ });
1304
+ }
1305
+
1306
+ await forceReconnectPeripheral(peripheral, deviceId);
1307
+ setupDisconnectListener(peripheral, deviceId, webContents);
1308
+ try {
1309
+ return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1310
+ } catch (reconnectError) {
1311
+ logger?.error(
1312
+ '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1313
+ reconnectError
1314
+ );
1183
1315
  return freshScanAndDiscover(deviceId, webContents);
1184
1316
  }
1185
1317
  }
1186
1318
 
1319
+ // noble/mac never resolves for an id CoreBluetooth cannot retrieve.
1320
+ const DIRECT_CONNECT_TIMEOUT_MS = 2000;
1321
+ // Floor after a timeout: do not pay the probe on every attempt.
1322
+ const DIRECT_CONNECT_COOLDOWN_MS = 15_000;
1323
+ const directConnectCooldownUntil = new Map<string, number>();
1324
+
1325
+ /**
1326
+ * Connect straight by id, skipping the ~650ms targeted scan. Both native
1327
+ * backends support it and emit a `discover` as a side effect: macOS resolves
1328
+ * via `retrievePeripheralsWithIdentifiers`, Windows synthesizes one for an
1329
+ * unknown address. Returns undefined when unavailable so the caller scans.
1330
+ *
1331
+ * Ported from the Trezor connector, where it is field-proven.
1332
+ */
1333
+ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
1334
+ const nobleInstance = noble as
1335
+ | (typeof noble & {
1336
+ connectAsync?: (id: string) => Promise<Peripheral | undefined>;
1337
+ })
1338
+ | null;
1339
+ if (!nobleInstance?.connectAsync) return undefined;
1340
+
1341
+ const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
1342
+ if (Date.now() < cooldownUntil) return undefined;
1343
+
1344
+ try {
1345
+ // The late-orphan guard must attach to THIS pending connect.
1346
+ const directPromise = nobleInstance.connectAsync(deviceId);
1347
+ const raced = await Promise.race([
1348
+ directPromise,
1349
+ new Promise<'timeout'>(resolve => {
1350
+ setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
1351
+ }),
1352
+ ]);
1353
+ if (raced === 'timeout') {
1354
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1355
+ logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1356
+ deviceId,
1357
+ });
1358
+ // Promise.race times out the caller only; a late success orphans the link.
1359
+ directPromise
1360
+ .then(late => {
1361
+ const latePeripheral = late ?? discoveredDevices.get(deviceId);
1362
+ if (
1363
+ latePeripheral &&
1364
+ latePeripheral.state === 'connected' &&
1365
+ !connectedDevices.has(deviceId)
1366
+ ) {
1367
+ latePeripheral.removeAllListeners('disconnect');
1368
+ latePeripheral.disconnect(() => undefined);
1369
+ }
1370
+ })
1371
+ .catch(() => undefined);
1372
+ return undefined;
1373
+ }
1374
+ // Backends emit `discover` as a side effect, so the cache may hold it.
1375
+ const peripheral = raced ?? discoveredDevices.get(deviceId);
1376
+ if (!peripheral || peripheral.state !== 'connected') return undefined;
1377
+ logger?.info('[NobleBLE] Direct connect-by-id succeeded', { deviceId });
1378
+ return peripheral;
1379
+ } catch (error) {
1380
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1381
+ logger?.info('[NobleBLE] Direct connect-by-id failed, falling back to scan', {
1382
+ deviceId,
1383
+ error: String(error),
1384
+ });
1385
+ return undefined;
1386
+ }
1387
+ }
1388
+
1187
1389
  // Connect to device - supports both discovered and direct connection modes
1188
1390
  async function connectDevice(deviceId: string, webContents: WebContents): Promise<void> {
1189
1391
  logger?.info('[NobleBLE] Connect device request:', {
@@ -1195,7 +1397,8 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1195
1397
  totalConnected: connectedDevices.size,
1196
1398
  });
1197
1399
 
1198
- let peripheral = discoveredDevices.get(deviceId);
1400
+ // enumerate clears the discovery map; a kept-alive link outlives it.
1401
+ let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1199
1402
 
1200
1403
  // If device not discovered, try a targeted scan for this specific device
1201
1404
  if (!peripheral) {
@@ -1210,6 +1413,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1210
1413
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1211
1414
  }
1212
1415
 
1416
+ // Reaches a bonded device that is not advertising; else falls to the scan.
1417
+ peripheral = await tryDirectConnectById(deviceId);
1418
+ if (peripheral) {
1419
+ discoveredDevices.set(deviceId, peripheral);
1420
+ }
1421
+ }
1422
+
1423
+ if (!peripheral) {
1213
1424
  // Perform a targeted scan to find the specific device
1214
1425
  try {
1215
1426
  const foundPeripheral = await performTargetedScan(deviceId);
@@ -1240,9 +1451,11 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1240
1451
  // If already connected but not in our connected devices map, add it
1241
1452
  if (!connectedDevices.has(deviceId)) {
1242
1453
  connectedDevices.set(deviceId, peripheral);
1243
- // Set up unified disconnect listener
1244
- setupDisconnectListener(peripheral, deviceId, webContents);
1245
1454
  }
1455
+ // Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
1456
+ // and MTU listeners may still hold the webContents of a soft-restarted
1457
+ // renderer, and the reuse fast path below returns before any other setup.
1458
+ setupDisconnectListener(peripheral, deviceId, webContents);
1246
1459
 
1247
1460
  // Check if we already have characteristics for this device
1248
1461
  if (deviceCharacteristics.has(deviceId)) {
@@ -1286,26 +1499,48 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1286
1499
  }
1287
1500
 
1288
1501
  // Setup connection and discover services
1289
- const characteristics = await setupConnectionAndDiscoverServices(
1290
- peripheral,
1291
- deviceId,
1292
- webContents
1293
- );
1294
- deviceCharacteristics.set(deviceId, characteristics);
1295
- logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1502
+ try {
1503
+ const characteristics = await setupConnectionAndDiscoverServices(
1504
+ peripheral,
1505
+ deviceId,
1506
+ webContents
1507
+ );
1508
+ deviceCharacteristics.set(deviceId, characteristics);
1509
+ logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1510
+ } catch (setupError) {
1511
+ // The caller re-arms the timer only on success, so a left-up link strands.
1512
+ logger?.error('[NobleBLE] Connection setup failed on kept-alive link:', setupError);
1513
+ await disconnectDevice(deviceId).catch(() => undefined);
1514
+ throw setupError;
1515
+ }
1296
1516
  return;
1297
1517
  }
1298
1518
 
1299
1519
  return new Promise((resolve, reject) => {
1520
+ let connectionTimedOut = false;
1300
1521
  const timeout = setTimeout(() => {
1522
+ connectionTimedOut = true;
1301
1523
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
1302
- }, CONNECTION_TIMEOUT);
1524
+ }, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
1303
1525
 
1304
1526
  // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1305
1527
  const connectedPeripheral = peripheral as Peripheral;
1306
1528
  connectedPeripheral.connect(async (error: Error | undefined) => {
1307
1529
  clearTimeout(timeout);
1308
1530
 
1531
+ // Noble may invoke the callback after the SDK timed out and released the request.
1532
+ // Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
1533
+ if (connectionTimedOut) {
1534
+ if (!error) {
1535
+ try {
1536
+ connectedPeripheral.disconnect(() => undefined);
1537
+ } catch {
1538
+ // Best-effort cleanup only.
1539
+ }
1540
+ }
1541
+ return;
1542
+ }
1543
+
1309
1544
  if (error) {
1310
1545
  logger?.error('[NobleBLE] Connection failed:', error);
1311
1546
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
@@ -1342,20 +1577,21 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1342
1577
  return;
1343
1578
  }
1344
1579
 
1345
- return new Promise<void>(resolve => {
1346
- // Remove disconnect listener to avoid triggering handleDeviceDisconnect
1347
- peripheral.removeAllListeners('disconnect');
1348
-
1349
- peripheral.disconnect(() => {
1350
- // Clean up device state using unified function
1351
- cleanupDevice(deviceId, undefined, {
1352
- cleanupConnection: true,
1353
- sendDisconnectEvent: false,
1354
- cancelOperations: true,
1355
- reason: 'manual-disconnect',
1356
- });
1357
- resolve();
1358
- });
1580
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
1581
+ if (disconnectEntry) {
1582
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
1583
+ deviceDisconnectListeners.delete(deviceId);
1584
+ }
1585
+
1586
+ await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1587
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1588
+ timeoutBehavior: 'resolve',
1589
+ });
1590
+ cleanupDevice(deviceId, undefined, {
1591
+ cleanupConnection: true,
1592
+ sendDisconnectEvent: false,
1593
+ cancelOperations: true,
1594
+ reason: 'manual-disconnect',
1359
1595
  });
1360
1596
  }
1361
1597
 
@@ -1376,17 +1612,11 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1376
1612
  subscriptionOperations.set(deviceId, 'unsubscribing');
1377
1613
 
1378
1614
  try {
1379
- await new Promise<void>((resolve, reject) => {
1380
- notifyCharacteristic.unsubscribe((error: Error | undefined) => {
1381
- if (error) {
1382
- logger?.error('[NobleBLE] Notification unsubscription failed:', error);
1383
- reject(error);
1384
- } else {
1385
- logger?.info('[NobleBLE] Notification unsubscription successful');
1386
- resolve();
1387
- }
1388
- });
1615
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1616
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1617
+ timeoutBehavior: 'resolve',
1389
1618
  });
1619
+ logger?.info('[NobleBLE] Notification unsubscription completed');
1390
1620
 
1391
1621
  // Remove all listeners and clear subscription status
1392
1622
  notifyCharacteristic.removeAllListeners('data');
@@ -1479,19 +1709,13 @@ async function subscribeNotifications(
1479
1709
  notifyCharacteristic: Characteristic
1480
1710
  ): Promise<void> {
1481
1711
  // Listeners already cleaned up above, no need to repeat
1482
- await new Promise<void>(resolve => {
1483
- notifyCharacteristic.unsubscribe(() => {
1484
- resolve();
1485
- });
1712
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1713
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1714
+ timeoutBehavior: 'resolve',
1486
1715
  });
1487
- await new Promise<void>((resolve, reject) => {
1488
- notifyCharacteristic.subscribe((error?: Error) => {
1489
- if (error) {
1490
- reject(error);
1491
- return;
1492
- }
1493
- resolve();
1494
- });
1716
+ await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1717
+ timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1718
+ timeoutBehavior: 'reject',
1495
1719
  });
1496
1720
 
1497
1721
  notifyCharacteristic.on('data', (data: Buffer) => {
@@ -1516,29 +1740,31 @@ async function subscribeNotifications(
1516
1740
 
1517
1741
  // Setup IPC handlers
1518
1742
  export function setupNobleBleHandlers(webContents: WebContents): void {
1519
- // Use console.log for initial logging as electron-log might not be available yet.
1520
- console.log('[NobleBLE] Attempting to set up Noble BLE handlers.');
1521
1743
  try {
1522
- console.log('[NobleBLE] NOBLE_VERSION_771');
1523
-
1524
1744
  // @ts-ignore – electron-log is only available at runtime
1525
1745
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1526
1746
  logger = require('electron-log') as Logger;
1527
- console.log('[NobleBLE] electron-log loaded successfully.');
1528
1747
 
1529
1748
  // @ts-ignore – electron is only available at runtime
1530
1749
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1531
- const { ipcMain } = require('electron');
1532
- console.log('[NobleBLE] electron.ipcMain loaded successfully.');
1750
+ const { ipcMain } = require('electron') as { ipcMain: IpcMain };
1751
+
1752
+ // Electron throws on duplicate channels and setup re-runs on soft restart.
1753
+ const handle: IpcMain['handle'] = (channel, listener) => {
1754
+ ipcMain.removeHandler(channel);
1755
+ ipcMain.handle(channel, listener);
1756
+ };
1533
1757
 
1534
1758
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
1535
1759
 
1536
1760
  // Handle enumerate request
1537
- console.log(`[NobleBLE] Registering handler for: ${EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE}`);
1538
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1761
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1539
1762
  try {
1540
1763
  const devices = await enumerateDevices();
1541
- safeLog(logger, 'info', 'Enumeration completed, devices:', devices);
1764
+ safeLog(logger, 'debug', 'Enumeration completed', {
1765
+ count: devices.length,
1766
+ devices: devices.map(device => ({ id: device.id, name: device.name })),
1767
+ });
1542
1768
  return devices;
1543
1769
  } catch (error) {
1544
1770
  safeLog(logger, 'error', 'Enumeration failed:', error);
@@ -1547,18 +1773,18 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1547
1773
  });
1548
1774
 
1549
1775
  // Handle stop scan request
1550
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, async () => {
1776
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, async () => {
1551
1777
  await stopScanning();
1552
1778
  });
1553
1779
 
1554
1780
  // Handle get device request
1555
- ipcMain.handle(
1781
+ handle(
1556
1782
  EOneKeyBleMessageKeys.NOBLE_BLE_GET_DEVICE,
1557
1783
  (_event: IpcMainInvokeEvent, deviceId: string) => getDevice(deviceId)
1558
1784
  );
1559
1785
 
1560
1786
  // Handle connect request
1561
- ipcMain.handle(
1787
+ handle(
1562
1788
  EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT,
1563
1789
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1564
1790
  logger?.info('[NobleBLE] IPC CONNECT request received:', {
@@ -1567,12 +1793,37 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1567
1793
  hasCharacteristics: deviceCharacteristics.has(deviceId),
1568
1794
  totalConnectedDevices: connectedDevices.size,
1569
1795
  });
1570
- await connectDevice(deviceId, webContents);
1796
+ // Must not fire mid-connect (pairing can take ~30s).
1797
+ clearIdleDisconnect(deviceId);
1798
+ // A fired timer must settle first, or the fast path returns a dying link.
1799
+ await awaitIdleDisconnect(deviceId);
1800
+ try {
1801
+ await connectDevice(deviceId, webContents);
1802
+ } finally {
1803
+ // This timer is the only thing that frees a kept-alive link.
1804
+ if (connectedDevices.has(deviceId)) {
1805
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1806
+ }
1807
+ }
1808
+ }
1809
+ );
1810
+
1811
+ // Logical release: start the idle countdown, keep the link for the next call.
1812
+ handle(
1813
+ EOneKeyBleMessageKeys.NOBLE_BLE_RELEASE,
1814
+ (_event: IpcMainInvokeEvent, deviceId: string, keepSession?: boolean) => {
1815
+ if (!connectedDevices.has(deviceId)) return;
1816
+ // Mid-flow caller will be back; the short window would cut an update.
1817
+ if (keepSession) {
1818
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1819
+ return;
1820
+ }
1821
+ armIdleDisconnect(deviceId);
1571
1822
  }
1572
1823
  );
1573
1824
 
1574
1825
  // Handle disconnect request
1575
- ipcMain.handle(
1826
+ handle(
1576
1827
  EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT,
1577
1828
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1578
1829
  await disconnectDevice(deviceId);
@@ -1580,35 +1831,42 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1580
1831
  );
1581
1832
 
1582
1833
  // Handle write request
1583
- ipcMain.handle(
1834
+ handle(
1584
1835
  EOneKeyBleMessageKeys.NOBLE_BLE_WRITE,
1585
- async (_event: IpcMainInvokeEvent, deviceId: string, hexData: string) => {
1586
- logger?.info('[NobleBLE] IPC WRITE', { deviceId, len: hexData.length });
1587
- await transmitHexDataToDevice(deviceId, hexData);
1836
+ async (
1837
+ _event: IpcMainInvokeEvent,
1838
+ deviceId: string,
1839
+ hexData: string,
1840
+ options?: NobleBleWriteOptions
1841
+ ) => {
1842
+ await transmitHexDataToDevice(deviceId, hexData, options);
1588
1843
  }
1589
1844
  );
1590
1845
 
1591
1846
  // Handle subscribe request
1592
- ipcMain.handle(
1847
+ handle(
1593
1848
  EOneKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE,
1594
1849
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1595
1850
  await subscribeNotifications(deviceId, (data: string) => {
1596
1851
  // Send data back to renderer process
1597
1852
  webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_NOTIFICATION, deviceId, data);
1598
1853
  });
1854
+ // Still acquiring (in flight) — busy backstop, not the idle clock.
1855
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1599
1856
  }
1600
1857
  );
1601
1858
 
1602
1859
  // Handle unsubscribe request
1603
- ipcMain.handle(
1860
+ handle(
1604
1861
  EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE,
1605
1862
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1606
1863
  await unsubscribeNotifications(deviceId);
1864
+ armIdleDisconnect(deviceId);
1607
1865
  }
1608
1866
  );
1609
1867
 
1610
1868
  // Handle cancel pairing: cleanup all connected devices
1611
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, async () => {
1869
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, async () => {
1612
1870
  const deviceIds = Array.from(connectedDevices.keys());
1613
1871
  logger?.info('[NobleBLE] Cancel pairing invoked', {
1614
1872
  platform: process.platform,
@@ -1629,7 +1887,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1629
1887
  });
1630
1888
 
1631
1889
  // Handle Bluetooth availability check request
1632
- ipcMain.handle(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, async () => {
1890
+ handle(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, async () => {
1633
1891
  try {
1634
1892
  const bluetoothStatus = await checkBluetoothAvailability();
1635
1893
  safeLog(logger, 'info', 'Bluetooth availability check completed:', bluetoothStatus);
@@ -1648,32 +1906,24 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1648
1906
  // Cleanup on app quit
1649
1907
  webContents.on('destroyed', () => {
1650
1908
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1909
+ (async () => {
1910
+ const deviceIds = Array.from(connectedDevices.keys());
1911
+ for (const deviceId of deviceIds) {
1912
+ await unsubscribeNotifications(deviceId).catch(() => undefined);
1913
+ await disconnectDevice(deviceId).catch(() => undefined);
1914
+ }
1651
1915
 
1652
- // 1. Clean up all connected devices (unified cleanup, avoid duplicates)
1653
- const deviceIds = Array.from(connectedDevices.keys());
1654
- deviceIds.forEach(deviceId => {
1655
- cleanupDevice(deviceId, undefined, {
1656
- cleanupConnection: true,
1657
- sendDisconnectEvent: false,
1658
- cancelOperations: true,
1659
- reason: 'app-quit',
1660
- });
1916
+ await stopScanning().catch(() => undefined);
1917
+ if (noble && persistentStateListener) {
1918
+ noble.removeListener('stateChange', persistentStateListener);
1919
+ persistentStateListener = null;
1920
+ }
1921
+ cleanupNobleListeners();
1922
+ discoveredDevices.clear();
1923
+ safeLog(logger, 'info', 'Noble BLE cleanup completed');
1924
+ })().catch(error => {
1925
+ safeLog(logger, 'error', 'Noble BLE cleanup failed', error);
1661
1926
  });
1662
-
1663
- // 2. Stop scanning
1664
- stopScanning();
1665
-
1666
- // 3. Clean up Noble listeners
1667
- if (noble && persistentStateListener) {
1668
- noble.removeListener('stateChange', persistentStateListener);
1669
- persistentStateListener = null;
1670
- }
1671
- cleanupNobleListeners();
1672
-
1673
- // 4. Clear discovered devices cache
1674
- discoveredDevices.clear();
1675
-
1676
- safeLog(logger, 'info', 'Noble BLE cleanup completed');
1677
1927
  });
1678
1928
 
1679
1929
  safeLog(logger, 'info', 'Noble BLE IPC handlers setup completed');