@onekeyfe/hd-transport-electron 1.2.0-alpha.13 → 1.2.0-alpha.131

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,28 @@ 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
+ matchesKnownBleUuid,
14
19
  wait,
15
20
  } from '@onekeyfe/hd-shared';
16
21
  import pRetry from 'p-retry';
17
22
 
23
+ import { resolveBlePacketCapacity } from './ble-packet-capacity';
18
24
  import { safeLog } from './types/noble-extended';
19
- import { softRefreshSubscription } from './ble-ops';
25
+ import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
26
+ import {
27
+ NOBLE_BLE_CONNECTION_TIMEOUT_MS,
28
+ NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
29
+ } from './noble-ble-timeouts';
20
30
 
21
- import type { IpcMainInvokeEvent, WebContents } from 'electron';
31
+ import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
22
32
  import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
33
+ import type { NobleBleWriteOptions } from './types/desktop-api';
23
34
  import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
24
35
 
25
36
  // Noble will be dynamically imported to avoid bundling issues
@@ -39,6 +50,7 @@ const bluetoothState: {
39
50
 
40
51
  // Global persistent state listener for app layer
41
52
  let persistentStateListener: ((state: string) => void) | null = null;
53
+ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null;
42
54
 
43
55
  // Device cache and connection state
44
56
  const discoveredDevices = new Map<string, Peripheral>();
@@ -50,6 +62,14 @@ const subscribedDevices = new Map<string, boolean>(); // Track subscription stat
50
62
 
51
63
  // 🔒 Subscription operation state tracking to prevent race conditions
52
64
  const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' | 'idle'>();
65
+ const deviceDisconnectListeners = new Map<
66
+ string,
67
+ { peripheral: Peripheral; listener: () => void }
68
+ >();
69
+ const deviceMtuListeners = new Map<
70
+ string,
71
+ { peripheral: Peripheral; listener: (mtu: number) => void }
72
+ >();
53
73
 
54
74
  // Windows-only response watchdog state moved to utils/windows-ble-recovery
55
75
 
@@ -59,57 +79,53 @@ const subscriptionOperations = new Map<string, 'subscribing' | 'unsubscribing' |
59
79
 
60
80
  // Service UUIDs to scan for - using constants from hd-shared
61
81
  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';
82
+ const ONEKEY_SERVICE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_SERVICE_UUID);
83
+ const ONEKEY_WRITE_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_WRITE_CHARACTERISTIC_UUID);
84
+ const ONEKEY_NOTIFY_UUID_ALIASES = createKnownBleUuidAliases(ONEKEY_NOTIFY_CHARACTERISTIC_UUID);
67
85
 
68
86
  // Timeout and interval constants
69
87
  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)
88
+ const DEVICE_SCAN_TIMEOUT = 5000; // 5 seconds for device scanning
72
89
  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
90
  const SERVICE_DISCOVERY_TIMEOUT = 10000; // 10 seconds for service discovery
91
+ const BLE_CLEANUP_TIMEOUT = 250;
92
+ // Renderer release is logical only; this timer physically frees the device.
93
+ const BLE_IDLE_DISCONNECT_MS = 3 * 60_000;
94
+ // Ceiling while a call is in flight: no outstanding write, but not forever.
95
+ const BLE_BUSY_BACKSTOP_MS = 10 * 60_000;
75
96
 
76
97
  // Write-related constants
77
- const BLE_PACKET_SIZE = 192;
78
- const UNIFIED_WRITE_DELAY = 5;
98
+ const BLE_PACKET_SIZE_FALLBACK = 192;
99
+ const BLE_PACKET_SIZE_MAXIMUM = 244;
100
+ const DEFAULT_WRITE_PACING_DELAY_MS = 5;
79
101
  const RETRY_CONFIG = { MAX_ATTEMPTS: 15, WRITE_TIMEOUT: 2000 } as const;
80
102
  const IS_WINDOWS = process.platform === 'win32';
81
103
  const ABORTABLE_WRITE_ERROR_PATTERNS = [
82
104
  /status:\s*3/i, // Windows pairing cancelled / GATT write failed
83
105
  ];
84
106
 
85
- function getBleUuidKey(uuid?: string | null) {
86
- const normalized = (uuid ?? '').replace(/-/g, '').toLowerCase();
87
- return normalized.length >= 8 ? normalized.substring(4, 8) : normalized;
107
+ export function resolveNobleBleWritePacingDelay(options?: NobleBleWriteOptions) {
108
+ return typeof options?.pacingDelayMs === 'number' && Number.isFinite(options.pacingDelayMs)
109
+ ? Math.min(Math.max(Math.floor(options.pacingDelayMs), 0), 1000)
110
+ : DEFAULT_WRITE_PACING_DELAY_MS;
88
111
  }
89
112
 
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
- }
113
+ function isOneKeyPeripheral(peripheral: Peripheral) {
114
+ const serviceUuids = peripheral.advertisement?.serviceUuids;
115
+ const localName = peripheral.advertisement?.localName;
98
116
 
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
- }
117
+ if (!localName?.trim()) {
118
+ return false;
119
+ }
109
120
 
110
- function isOneKeyPeripheral(peripheral: Peripheral) {
111
- const deviceName = peripheral.advertisement?.localName || null;
112
- return isOnekeyDevice(deviceName, peripheral.id) || hasOneKeyAdvertisementService(peripheral);
121
+ return (
122
+ hasOnekeyCommunicationService(serviceUuids) &&
123
+ isOnekeyBluetoothDevice({
124
+ id: peripheral.id,
125
+ localName,
126
+ serviceUuids,
127
+ })
128
+ );
113
129
  }
114
130
 
115
131
  /**
@@ -290,9 +306,12 @@ async function initializeNoble(): Promise<void> {
290
306
  });
291
307
 
292
308
  // Set up device discovery
293
- noble.on('discover', (peripheral: Peripheral) => {
294
- handleDeviceDiscovered(peripheral);
295
- });
309
+ if (!persistentDiscoverListener) {
310
+ persistentDiscoverListener = (peripheral: Peripheral) => {
311
+ handleDeviceDiscovered(peripheral);
312
+ };
313
+ noble.on('discover', persistentDiscoverListener);
314
+ }
296
315
 
297
316
  logger?.info('[NobleBLE] Noble initialized successfully');
298
317
  } catch (error) {
@@ -323,6 +342,76 @@ interface DeviceCleanupOptions {
323
342
  reason?: string;
324
343
  }
325
344
 
345
+ // One timer per device, in the main process so a reload cannot orphan a link.
346
+ const idleDisconnectTimers = new Map<string, ReturnType<typeof setTimeout>>();
347
+
348
+ // A fired timer cannot be cancelled; connect awaits its disconnect or the fast
349
+ // path hands back a link torn down a moment later (BleTimeoutError 713).
350
+ const idleDisconnectInFlight = new Map<string, Promise<void>>();
351
+
352
+ function clearIdleDisconnect(deviceId: string): void {
353
+ const timer = idleDisconnectTimers.get(deviceId);
354
+ if (timer) {
355
+ clearTimeout(timer);
356
+ idleDisconnectTimers.delete(deviceId);
357
+ }
358
+ }
359
+
360
+ /** Settle any keep-alive disconnect already running for this device. */
361
+ async function awaitIdleDisconnect(deviceId: string): Promise<void> {
362
+ const pending = idleDisconnectInFlight.get(deviceId);
363
+ if (pending) await pending.catch(() => undefined);
364
+ }
365
+
366
+ function broadcastToAllWebContents(channel: string, payload: unknown): void {
367
+ try {
368
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
369
+ const { webContents: electronWebContents } = require('electron') as {
370
+ webContents: { getAllWebContents(): WebContents[] };
371
+ };
372
+ for (const wc of electronWebContents.getAllWebContents()) {
373
+ if (!wc.isDestroyed()) wc.send(channel, payload);
374
+ }
375
+ } catch (error) {
376
+ logger?.error('[NobleBLE] broadcast failed:', { channel, error: String(error) });
377
+ }
378
+ }
379
+
380
+ function armIdleDisconnect(
381
+ deviceId: string,
382
+ ms: number = BLE_IDLE_DISCONNECT_MS,
383
+ reason: 'idle' | 'busy-backstop' = 'idle'
384
+ ): void {
385
+ clearIdleDisconnect(deviceId);
386
+ idleDisconnectTimers.set(
387
+ deviceId,
388
+ setTimeout(() => {
389
+ idleDisconnectTimers.delete(deviceId);
390
+ if (!connectedDevices.has(deviceId)) return;
391
+ logger?.info('[NobleBLE] Keep-alive timeout, disconnecting device', { deviceId, reason });
392
+ const peripheral = connectedDevices.get(deviceId);
393
+ const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
394
+ const pending = disconnectDevice(deviceId)
395
+ .then(() => {
396
+ // A call is still in flight here; it must reject, not hang.
397
+ broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
398
+ id: deviceId,
399
+ name: deviceName,
400
+ });
401
+ })
402
+ .catch(error => {
403
+ logger?.error('[NobleBLE] Keep-alive disconnect failed', { deviceId, error });
404
+ })
405
+ .finally(() => {
406
+ if (idleDisconnectInFlight.get(deviceId) === pending) {
407
+ idleDisconnectInFlight.delete(deviceId);
408
+ }
409
+ });
410
+ idleDisconnectInFlight.set(deviceId, pending);
411
+ }, ms)
412
+ );
413
+ }
414
+
326
415
  /**
327
416
  * Unified device cleanup function - single entry point for all cleanup operations
328
417
  */
@@ -348,12 +437,24 @@ function cleanupDevice(
348
437
  cancelOperations,
349
438
  });
350
439
 
440
+ clearIdleDisconnect(deviceId);
441
+
351
442
  // Get device info before cleanup
352
443
  const peripheral = connectedDevices.get(deviceId);
353
444
  const deviceName = peripheral?.advertisement?.localName || 'Unknown Device';
354
445
 
355
446
  // 1. Clean up connection state
356
447
  if (cleanupConnection) {
448
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
449
+ if (disconnectEntry) {
450
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
451
+ deviceDisconnectListeners.delete(deviceId);
452
+ }
453
+ const mtuEntry = deviceMtuListeners.get(deviceId);
454
+ if (mtuEntry) {
455
+ mtuEntry.peripheral.removeListener('mtu', mtuEntry.listener);
456
+ deviceMtuListeners.delete(deviceId);
457
+ }
357
458
  connectedDevices.delete(deviceId);
358
459
  deviceCharacteristics.delete(deviceId);
359
460
  notificationCallbacks.delete(deviceId);
@@ -367,9 +468,11 @@ function cleanupDevice(
367
468
  discoveredDevices.delete(deviceId);
368
469
  }
369
470
 
370
- // 3. Send disconnect event (if needed)
371
- if (sendDisconnectEvent && webContents) {
372
- webContents.send(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
471
+ // 3. Send disconnect event (if needed). Broadcast, not the captured
472
+ // webContents: a kept-alive link outlives a renderer soft restart, and the
473
+ // new renderer still needs to hear the device dropped (same as the idle path).
474
+ if (sendDisconnectEvent) {
475
+ broadcastToAllWebContents(EOneKeyBleMessageKeys.BLE_DEVICE_DISCONNECTED, {
373
476
  id: deviceId,
374
477
  name: deviceName,
375
478
  });
@@ -403,18 +506,45 @@ function setupDisconnectListener(
403
506
  deviceId: string,
404
507
  webContents: WebContents
405
508
  ): void {
406
- // Remove any existing disconnect listeners to avoid duplicates
407
- peripheral.removeAllListeners('disconnect');
509
+ const existing = deviceDisconnectListeners.get(deviceId);
510
+ if (existing) {
511
+ existing.peripheral.removeListener('disconnect', existing.listener);
512
+ }
408
513
 
409
- // Set up new disconnect listener
410
- peripheral.on('disconnect', () => {
514
+ const listener = () => {
411
515
  handleDeviceDisconnect(deviceId, webContents);
412
- });
516
+ };
517
+ deviceDisconnectListeners.set(deviceId, { peripheral, listener });
518
+ peripheral.on('disconnect', listener);
519
+ setupMtuListener(peripheral, deviceId, webContents);
520
+ }
521
+
522
+ function setupMtuListener(
523
+ peripheral: Peripheral,
524
+ deviceId: string,
525
+ webContents: WebContents
526
+ ): void {
527
+ const existing = deviceMtuListeners.get(deviceId);
528
+ if (existing) {
529
+ existing.peripheral.removeListener('mtu', existing.listener);
530
+ }
531
+
532
+ const listener = (mtu: number) => {
533
+ if (!Number.isFinite(mtu) || mtu <= 0) return;
534
+ // A kept-alive link can outlive the renderer this closure captured.
535
+ if (webContents.isDestroyed()) return;
536
+ webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_MTU_CHANGED, {
537
+ id: deviceId,
538
+ mtu,
539
+ });
540
+ };
541
+ deviceMtuListeners.set(deviceId, { peripheral, listener });
542
+ peripheral.on('mtu', listener);
413
543
  }
414
544
 
415
545
  // ===== Write helpers (inline) =====
416
546
 
417
- async function writeCharacteristicWithAck(
547
+ async function writeCharacteristicWithoutResponse(
418
548
  deviceId: string,
419
549
  writeCharacteristic: Characteristic,
420
550
  buffer: Buffer
@@ -462,7 +592,7 @@ async function attemptWindowsWriteUntilPaired(
462
592
  }
463
593
 
464
594
  try {
465
- await writeCharacteristicWithAck(deviceId, latestWrite, payload);
595
+ await writeCharacteristicWithoutResponse(deviceId, latestWrite, payload);
466
596
  } catch (e) {
467
597
  const errorMessage = e instanceof Error ? e.message : String(e);
468
598
  logger?.error('[BLE-Write] Windows write error', {
@@ -534,7 +664,11 @@ async function attemptWindowsWriteUntilPaired(
534
664
  );
535
665
  }
536
666
 
537
- async function transmitHexDataToDevice(deviceId: string, hexData: string): Promise<void> {
667
+ async function transmitHexDataToDevice(
668
+ deviceId: string,
669
+ hexData: string,
670
+ options?: NobleBleWriteOptions
671
+ ): Promise<void> {
538
672
  const characteristics = deviceCharacteristics.get(deviceId);
539
673
  const peripheral = connectedDevices.get(deviceId);
540
674
  if (!peripheral || !characteristics) {
@@ -543,9 +677,17 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
543
677
  `Device ${deviceId} not connected or characteristics not available`
544
678
  );
545
679
  }
680
+ // Request outstanding: swap the idle clock for the busy backstop.
681
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
546
682
 
547
683
  const toBuffer = Buffer.from(hexData, 'hex');
548
684
  const doGetWriteCharacteristic = () => deviceCharacteristics.get(deviceId)?.write;
685
+ const packetCapacity = resolveBlePacketCapacity(
686
+ peripheral.mtu,
687
+ BLE_PACKET_SIZE_MAXIMUM,
688
+ BLE_PACKET_SIZE_FALLBACK
689
+ );
690
+ const pacingDelayMs = resolveNobleBleWritePacingDelay(options);
549
691
 
550
692
  if (!IS_WINDOWS || pairedDevices.has(deviceId)) {
551
693
  // macOS / Linux or already paired on Windows: direct write
@@ -556,14 +698,14 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
556
698
  `Write characteristic not available for ${deviceId}`
557
699
  );
558
700
  }
559
- if (toBuffer.length <= BLE_PACKET_SIZE) {
560
- await wait(UNIFIED_WRITE_DELAY);
561
- await writeCharacteristicWithAck(deviceId, writeCharacteristic, toBuffer);
701
+ if (toBuffer.length <= packetCapacity) {
702
+ if (pacingDelayMs > 0) await wait(pacingDelayMs);
703
+ await writeCharacteristicWithoutResponse(deviceId, writeCharacteristic, toBuffer);
562
704
  return;
563
705
  }
564
706
  // chunked
565
707
  for (let offset = 0; offset < toBuffer.length; ) {
566
- const chunkSize = Math.min(BLE_PACKET_SIZE, toBuffer.length - offset);
708
+ const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
567
709
  const chunk = toBuffer.subarray(offset, offset + chunkSize);
568
710
  offset += chunkSize;
569
711
  const latest = doGetWriteCharacteristic();
@@ -573,23 +715,23 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
573
715
  `Write characteristic not available for ${deviceId}`
574
716
  );
575
717
  }
576
- await writeCharacteristicWithAck(deviceId, latest, chunk);
577
- if (offset < toBuffer.length) {
578
- await wait(UNIFIED_WRITE_DELAY);
718
+ await writeCharacteristicWithoutResponse(deviceId, latest, chunk);
719
+ if (offset < toBuffer.length && pacingDelayMs > 0) {
720
+ await wait(pacingDelayMs);
579
721
  }
580
722
  }
581
723
  return;
582
724
  }
583
725
 
584
726
  // Windows unpaired path: use loop
585
- if (toBuffer.length <= BLE_PACKET_SIZE) {
586
- await wait(UNIFIED_WRITE_DELAY);
727
+ if (toBuffer.length <= packetCapacity) {
728
+ if (pacingDelayMs > 0) await wait(pacingDelayMs);
587
729
  await attemptWindowsWriteUntilPaired(deviceId, doGetWriteCharacteristic, toBuffer, 'single');
588
730
  return;
589
731
  }
590
732
  // chunked loop
591
733
  for (let offset = 0, idx = 0; offset < toBuffer.length; idx++) {
592
- const chunkSize = Math.min(BLE_PACKET_SIZE, toBuffer.length - offset);
734
+ const chunkSize = Math.min(packetCapacity, toBuffer.length - offset);
593
735
  const chunk = toBuffer.subarray(offset, offset + chunkSize);
594
736
  offset += chunkSize;
595
737
  await attemptWindowsWriteUntilPaired(
@@ -598,8 +740,8 @@ async function transmitHexDataToDevice(deviceId: string, hexData: string): Promi
598
740
  chunk,
599
741
  `chunk-${idx + 1}`
600
742
  );
601
- if (offset < toBuffer.length) {
602
- await wait(UNIFIED_WRITE_DELAY);
743
+ if (offset < toBuffer.length && pacingDelayMs > 0) {
744
+ await wait(pacingDelayMs);
603
745
  }
604
746
  }
605
747
  }
@@ -628,19 +770,24 @@ function handleDeviceDiscovered(peripheral: Peripheral): void {
628
770
  function ensureDiscoverListener(): void {
629
771
  if (!noble) return;
630
772
 
631
- // Check if discover listener exists by checking listener count
632
- const listenerCount = (noble as any).listenerCount('discover');
633
-
634
- if (listenerCount === 0) {
773
+ if (!persistentDiscoverListener) {
635
774
  logger?.info('[NobleBLE] Discover listener missing, re-adding it');
636
- noble.on('discover', (peripheral: Peripheral) => {
775
+ persistentDiscoverListener = (peripheral: Peripheral) => {
637
776
  handleDeviceDiscovered(peripheral);
638
- });
777
+ };
778
+ noble.on('discover', persistentDiscoverListener);
639
779
  } else {
640
- logger?.debug('[NobleBLE] Discover listener already exists, count:', listenerCount);
780
+ logger?.debug('[NobleBLE] Discover listener already registered');
641
781
  }
642
782
  }
643
783
 
784
+ async function waitForNobleScanStop(nobleInstance: NobleModule): Promise<void> {
785
+ await runBleCallbackOperation(callback => nobleInstance.stopScanning(() => callback()), {
786
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
787
+ timeoutBehavior: 'resolve',
788
+ });
789
+ }
790
+
644
791
  // Perform targeted scan for a specific device ID
645
792
  // Uses self-contained local listener pattern - no global state needed
646
793
  async function performTargetedScan(targetDeviceId: string): Promise<Peripheral | null> {
@@ -654,39 +801,50 @@ async function performTargetedScan(targetDeviceId: string): Promise<Peripheral |
654
801
  logger?.info('[NobleBLE] Starting targeted scan for device:', targetDeviceId);
655
802
 
656
803
  return new Promise((resolve, reject) => {
804
+ let settled = false;
805
+
806
+ const finish = async (peripheral: Peripheral | null, error?: Error) => {
807
+ if (settled) return;
808
+ settled = true;
809
+ if (timeoutId) clearTimeout(timeoutId);
810
+ nobleInstance.removeListener('discover', onDiscover);
811
+ await waitForNobleScanStop(nobleInstance);
812
+
813
+ if (error) {
814
+ logger?.error('[NobleBLE] Failed to start targeted scan:', error);
815
+ reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
816
+ return;
817
+ }
818
+ if (peripheral) {
819
+ discoveredDevices.set(peripheral.id, peripheral);
820
+ }
821
+ resolve(peripheral);
822
+ };
823
+
657
824
  // Local discover listener - only matches target device
658
825
  const onDiscover = (peripheral: Peripheral) => {
659
- if (peripheral.id === targetDeviceId) {
826
+ if (peripheral.id === targetDeviceId && isOneKeyPeripheral(peripheral)) {
660
827
  logger?.info('[NobleBLE] Target device found during targeted scan:', {
661
828
  id: peripheral.id,
662
829
  name: peripheral.advertisement?.localName,
663
830
  });
664
- clearTimeout(timeoutId);
665
- nobleInstance.removeListener('discover', onDiscover);
666
- nobleInstance.stopScanning();
667
- discoveredDevices.set(peripheral.id, peripheral);
668
- resolve(peripheral);
831
+ finish(peripheral).catch(reject);
669
832
  }
670
833
  };
671
834
 
672
- // Timeout handler - must be after onDiscover so it can reference it
673
835
  const timeoutId = setTimeout(() => {
674
- nobleInstance.removeListener('discover', onDiscover);
675
- nobleInstance.stopScanning();
676
836
  logger?.info('[NobleBLE] Targeted scan timeout for device:', targetDeviceId);
677
- resolve(null);
678
- }, FAST_SCAN_TIMEOUT);
837
+ finish(null).catch(reject);
838
+ }, NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS);
679
839
 
680
840
  // Add local listener for this scan
681
841
  nobleInstance.on('discover', onDiscover);
682
842
 
683
- // Start scanning — no service UUID filter (Pro2 may use different service UUID)
684
- nobleInstance.startScanning([], false, (error?: Error) => {
843
+ // Allow repeated advertisements so a service-only packet can be followed by
844
+ // the named scan response needed to validate the target peripheral.
845
+ nobleInstance.startScanning([], true, (error?: Error) => {
685
846
  if (error) {
686
- clearTimeout(timeoutId);
687
- nobleInstance.removeListener('discover', onDiscover);
688
- logger?.error('[NobleBLE] Failed to start targeted scan:', error);
689
- reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
847
+ finish(null, error).catch(reject);
690
848
  return;
691
849
  }
692
850
  logger?.info('[NobleBLE] Targeted scan started for device:', targetDeviceId);
@@ -720,44 +878,47 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
720
878
  const devices: DeviceInfo[] = [];
721
879
  let intervalId: ReturnType<typeof setInterval> | undefined;
722
880
 
723
- // Cleanup function: clears both timeout and interval
724
- const cleanup = () => {
881
+ // Cleanup function: clears timers and waits until Noble confirms scanning
882
+ // has stopped. Resolving enumerate before this callback creates a race with
883
+ // an immediately-following connection attempt.
884
+ const cleanup = async () => {
725
885
  clearTimeout(timeoutId);
726
886
  if (intervalId) clearInterval(intervalId);
727
- nobleInstance.stopScanning();
887
+ await waitForNobleScanStop(nobleInstance);
728
888
  };
729
889
 
730
890
  // Collect discovered devices into the devices array
731
- const checkDevices = () => {
732
- discoveredDevices.forEach((peripheral, id) => {
733
- const existingDevice = devices.find(d => d.id === id);
734
- if (!existingDevice) {
735
- const deviceName = peripheral.advertisement?.localName || 'Unknown Device';
736
- devices.push({
737
- commType: 'electron-ble',
738
- id,
739
- name: deviceName,
740
- state: peripheral.state || 'disconnected',
741
- });
742
- }
891
+ const pushDevice = (peripheral: Peripheral, id: string) => {
892
+ if (devices.some(d => d.id === id)) return;
893
+ devices.push({
894
+ commType: 'electron-ble',
895
+ id,
896
+ name: peripheral.advertisement?.localName || 'Unknown Device',
897
+ state: peripheral.state || 'disconnected',
743
898
  });
744
899
  };
900
+ const checkDevices = () => {
901
+ discoveredDevices.forEach(pushDevice);
902
+ // A linked device stops advertising, so the scan never rediscovers it.
903
+ connectedDevices.forEach(pushDevice);
904
+ };
745
905
 
746
906
  // Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
747
- const timeoutId = setTimeout(() => {
907
+ const timeoutId = setTimeout(async () => {
748
908
  // Final collection before resolving — catches devices discovered near the deadline
749
909
  checkDevices();
750
- cleanup();
910
+ await cleanup();
751
911
  logger?.info('[NobleBLE] Scan completed, found devices:', devices.length);
752
912
  resolve(devices);
753
913
  }, DEVICE_SCAN_TIMEOUT);
754
914
 
755
915
  // Start scanning without a service UUID filter so Pro2 advertisements with
756
- // short vendor UUIDs can be found, but only OneKey candidates are logged/returned.
916
+ // short vendor UUIDs can be found. Repeated advertisements are required when
917
+ // the local name arrives in a later scan response; discoveredDevices handles deduplication.
757
918
  logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
758
- nobleInstance.startScanning([], false, (error?: Error) => {
919
+ nobleInstance.startScanning([], true, async (error?: Error) => {
759
920
  if (error) {
760
- cleanup();
921
+ await cleanup();
761
922
  logger?.error('[NobleBLE] Failed to start scanning:', error);
762
923
  reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
763
924
  return;
@@ -774,30 +935,21 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
774
935
  // Stop scanning
775
936
  async function stopScanning(): Promise<void> {
776
937
  if (!noble) return;
777
-
778
- return new Promise<void>(resolve => {
779
- if (!noble) {
780
- resolve();
781
- return;
782
- }
783
-
784
- noble.stopScanning(() => {
785
- logger?.info('[NobleBLE] Scanning stopped');
786
- resolve();
787
- });
788
- });
938
+ const nobleInstance = noble;
939
+ await waitForNobleScanStop(nobleInstance);
940
+ logger?.info('[NobleBLE] Scanning stopped');
789
941
  }
790
942
 
791
943
  // Clean up all Noble listeners (for app exit)
792
944
  function cleanupNobleListeners(): void {
793
945
  if (!noble) return;
794
946
 
795
- // Remove all listeners to prevent memory leaks
796
- // Noble uses EventEmitter, need to use removeAllListeners
797
947
  try {
798
- (noble as any).removeAllListeners('discover');
799
- (noble as any).removeAllListeners('stateChange');
800
- logger?.info('[NobleBLE] All Noble listeners cleaned up');
948
+ if (persistentDiscoverListener) {
949
+ noble.removeListener('discover', persistentDiscoverListener);
950
+ persistentDiscoverListener = null;
951
+ }
952
+ logger?.info('[NobleBLE] Owned Noble listeners cleaned up');
801
953
  } catch (error) {
802
954
  logger?.error('[NobleBLE] Failed to clean up some listeners:', error);
803
955
  }
@@ -814,6 +966,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
814
966
  id: peripheral.id,
815
967
  name: deviceName,
816
968
  state: peripheral.state || 'disconnected',
969
+ ...(typeof peripheral.mtu === 'number' ? { mtu: peripheral.mtu } : {}),
817
970
  };
818
971
  }
819
972
 
@@ -826,6 +979,7 @@ function getDevice(deviceId: string): DeviceInfo | null {
826
979
  id: connectedPeripheral.id,
827
980
  name: deviceName,
828
981
  state: connectedPeripheral.state || 'connected',
982
+ ...(typeof connectedPeripheral.mtu === 'number' ? { mtu: connectedPeripheral.mtu } : {}),
829
983
  };
830
984
  }
831
985
 
@@ -905,14 +1059,7 @@ async function discoverServicesAndCharacteristics(
905
1059
  });
906
1060
 
907
1061
  // Find OneKey service — Noble may expose 128-bit UUIDs as short UUID keys.
908
- let service = services.find(s => NORMALIZED_ONEKEY_SERVICE_UUIDS.has(getBleUuidKey(s.uuid)));
909
- if (!service) {
910
- logger?.info('[NobleBLE] Known OneKey service UUID not found, trying first vendor service');
911
- service =
912
- services.find(s => PRO2_ADVERTISEMENT_SERVICE_UUID_KEYS.has(getBleUuidKey(s.uuid))) ||
913
- services.find(s => !isGenericBleService(s.uuid)) ||
914
- services[0];
915
- }
1062
+ const service = services.find(s => matchesKnownBleUuid(s.uuid, ONEKEY_SERVICE_UUID_ALIASES));
916
1063
  if (!service) {
917
1064
  throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound);
918
1065
  }
@@ -938,12 +1085,9 @@ async function discoverServicesAndCharacteristics(
938
1085
  let notifyCharacteristic: Characteristic | null = null;
939
1086
 
940
1087
  for (const characteristic of characteristics) {
941
- const uuid = characteristic.uuid.replace(/-/g, '').toLowerCase();
942
- const uuidKey = uuid.length >= 8 ? uuid.substring(4, 8) : uuid;
943
-
944
- if (uuidKey === NORMALIZED_WRITE_UUID) {
1088
+ if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_WRITE_UUID_ALIASES)) {
945
1089
  writeCharacteristic = characteristic;
946
- } else if (uuidKey === NORMALIZED_NOTIFY_UUID) {
1090
+ } else if (matchesKnownBleUuid(characteristic.uuid, ONEKEY_NOTIFY_UUID_ALIASES)) {
947
1091
  notifyCharacteristic = characteristic;
948
1092
  }
949
1093
  }
@@ -1000,29 +1144,23 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
1000
1144
 
1001
1145
  // Step 2: Force disconnect if connected
1002
1146
  if (peripheral.state === 'connected') {
1003
- // Remove disconnect listener before intentional disconnect to avoid spurious events
1004
- peripheral.removeAllListeners('disconnect');
1005
- await new Promise<void>(resolve => {
1006
- peripheral.disconnect(() => {
1007
- logger?.info('[NobleBLE] Force disconnect completed');
1008
- resolve();
1009
- });
1010
- });
1147
+ await runBleCallbackOperation(
1148
+ callback =>
1149
+ peripheral.disconnect(() => {
1150
+ logger?.info('[NobleBLE] Force disconnect completed');
1151
+ callback();
1152
+ }),
1153
+ { timeoutMs: BLE_CLEANUP_TIMEOUT, timeoutBehavior: 'resolve' }
1154
+ );
1011
1155
  }
1012
1156
 
1013
1157
  // Step 3: Re-establish connection
1014
- await new Promise<void>((resolve, reject) => {
1015
- peripheral.connect((error: Error | undefined) => {
1016
- if (error) {
1017
- logger?.error('[NobleBLE] Force reconnect failed:', error);
1018
- reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
1019
- } else {
1020
- logger?.info('[NobleBLE] Force reconnect successful');
1021
- connectedDevices.set(deviceId, peripheral);
1022
- resolve();
1023
- }
1024
- });
1158
+ await runBleCallbackOperation(callback => peripheral.connect(callback), {
1159
+ timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1160
+ timeoutBehavior: 'reject',
1025
1161
  });
1162
+ logger?.info('[NobleBLE] Force reconnect successful');
1163
+ connectedDevices.set(deviceId, peripheral);
1026
1164
 
1027
1165
  // Wait for connection to stabilize
1028
1166
  await wait(500);
@@ -1153,20 +1291,100 @@ async function setupConnectionAndDiscoverServices(
1153
1291
  deviceId: string,
1154
1292
  webContents: WebContents
1155
1293
  ): Promise<CharacteristicPair> {
1156
- // Force reconnect to clear GATT cache
1157
- await forceReconnectPeripheral(peripheral, deviceId);
1158
1294
  setupDisconnectListener(peripheral, deviceId, webContents);
1159
1295
 
1160
- // Discover services and characteristics with retry
1161
1296
  try {
1162
1297
  return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1163
- } catch (error) {
1164
- // Last resort: fresh scan to get new peripheral object
1165
- logger?.error('[NobleBLE] Service discovery failed, attempting fresh scan...', error);
1298
+ } catch (initialError) {
1299
+ logger?.info('[NobleBLE] Direct service discovery failed, retrying after reconnect', {
1300
+ deviceId,
1301
+ error: initialError,
1302
+ });
1303
+ }
1304
+
1305
+ await forceReconnectPeripheral(peripheral, deviceId);
1306
+ setupDisconnectListener(peripheral, deviceId, webContents);
1307
+ try {
1308
+ return await discoverServicesAndCharacteristicsWithRetry(peripheral, deviceId);
1309
+ } catch (reconnectError) {
1310
+ logger?.error(
1311
+ '[NobleBLE] Service discovery failed after reconnect, attempting fresh scan...',
1312
+ reconnectError
1313
+ );
1166
1314
  return freshScanAndDiscover(deviceId, webContents);
1167
1315
  }
1168
1316
  }
1169
1317
 
1318
+ // noble/mac never resolves for an id CoreBluetooth cannot retrieve.
1319
+ const DIRECT_CONNECT_TIMEOUT_MS = 2000;
1320
+ // Floor after a timeout: do not pay the probe on every attempt.
1321
+ const DIRECT_CONNECT_COOLDOWN_MS = 15_000;
1322
+ const directConnectCooldownUntil = new Map<string, number>();
1323
+
1324
+ /**
1325
+ * Connect straight by id, skipping the ~650ms targeted scan. Both native
1326
+ * backends support it and emit a `discover` as a side effect: macOS resolves
1327
+ * via `retrievePeripheralsWithIdentifiers`, Windows synthesizes one for an
1328
+ * unknown address. Returns undefined when unavailable so the caller scans.
1329
+ *
1330
+ * Ported from the Trezor connector, where it is field-proven.
1331
+ */
1332
+ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
1333
+ const nobleInstance = noble as
1334
+ | (typeof noble & {
1335
+ connectAsync?: (id: string) => Promise<Peripheral | undefined>;
1336
+ })
1337
+ | null;
1338
+ if (!nobleInstance?.connectAsync) return undefined;
1339
+
1340
+ const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
1341
+ if (Date.now() < cooldownUntil) return undefined;
1342
+
1343
+ try {
1344
+ // The late-orphan guard must attach to THIS pending connect.
1345
+ const directPromise = nobleInstance.connectAsync(deviceId);
1346
+ const raced = await Promise.race([
1347
+ directPromise,
1348
+ new Promise<'timeout'>(resolve => {
1349
+ setTimeout(() => resolve('timeout'), DIRECT_CONNECT_TIMEOUT_MS);
1350
+ }),
1351
+ ]);
1352
+ if (raced === 'timeout') {
1353
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1354
+ logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
1355
+ deviceId,
1356
+ });
1357
+ // Promise.race times out the caller only; a late success orphans the link.
1358
+ directPromise
1359
+ .then(late => {
1360
+ const latePeripheral = late ?? discoveredDevices.get(deviceId);
1361
+ if (
1362
+ latePeripheral &&
1363
+ latePeripheral.state === 'connected' &&
1364
+ !connectedDevices.has(deviceId)
1365
+ ) {
1366
+ latePeripheral.removeAllListeners('disconnect');
1367
+ latePeripheral.disconnect(() => undefined);
1368
+ }
1369
+ })
1370
+ .catch(() => undefined);
1371
+ return undefined;
1372
+ }
1373
+ // Backends emit `discover` as a side effect, so the cache may hold it.
1374
+ const peripheral = raced ?? discoveredDevices.get(deviceId);
1375
+ if (!peripheral || peripheral.state !== 'connected') return undefined;
1376
+ logger?.info('[NobleBLE] Direct connect-by-id succeeded', { deviceId });
1377
+ return peripheral;
1378
+ } catch (error) {
1379
+ directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
1380
+ logger?.info('[NobleBLE] Direct connect-by-id failed, falling back to scan', {
1381
+ deviceId,
1382
+ error: String(error),
1383
+ });
1384
+ return undefined;
1385
+ }
1386
+ }
1387
+
1170
1388
  // Connect to device - supports both discovered and direct connection modes
1171
1389
  async function connectDevice(deviceId: string, webContents: WebContents): Promise<void> {
1172
1390
  logger?.info('[NobleBLE] Connect device request:', {
@@ -1178,7 +1396,8 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1178
1396
  totalConnected: connectedDevices.size,
1179
1397
  });
1180
1398
 
1181
- let peripheral = discoveredDevices.get(deviceId);
1399
+ // enumerate clears the discovery map; a kept-alive link outlives it.
1400
+ let peripheral = discoveredDevices.get(deviceId) ?? connectedDevices.get(deviceId);
1182
1401
 
1183
1402
  // If device not discovered, try a targeted scan for this specific device
1184
1403
  if (!peripheral) {
@@ -1193,6 +1412,14 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1193
1412
  throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
1194
1413
  }
1195
1414
 
1415
+ // Reaches a bonded device that is not advertising; else falls to the scan.
1416
+ peripheral = await tryDirectConnectById(deviceId);
1417
+ if (peripheral) {
1418
+ discoveredDevices.set(deviceId, peripheral);
1419
+ }
1420
+ }
1421
+
1422
+ if (!peripheral) {
1196
1423
  // Perform a targeted scan to find the specific device
1197
1424
  try {
1198
1425
  const foundPeripheral = await performTargetedScan(deviceId);
@@ -1223,9 +1450,11 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1223
1450
  // If already connected but not in our connected devices map, add it
1224
1451
  if (!connectedDevices.has(deviceId)) {
1225
1452
  connectedDevices.set(deviceId, peripheral);
1226
- // Set up unified disconnect listener
1227
- setupDisconnectListener(peripheral, deviceId, webContents);
1228
1453
  }
1454
+ // Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
1455
+ // and MTU listeners may still hold the webContents of a soft-restarted
1456
+ // renderer, and the reuse fast path below returns before any other setup.
1457
+ setupDisconnectListener(peripheral, deviceId, webContents);
1229
1458
 
1230
1459
  // Check if we already have characteristics for this device
1231
1460
  if (deviceCharacteristics.has(deviceId)) {
@@ -1269,26 +1498,48 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
1269
1498
  }
1270
1499
 
1271
1500
  // Setup connection and discover services
1272
- const characteristics = await setupConnectionAndDiscoverServices(
1273
- peripheral,
1274
- deviceId,
1275
- webContents
1276
- );
1277
- deviceCharacteristics.set(deviceId, characteristics);
1278
- logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1501
+ try {
1502
+ const characteristics = await setupConnectionAndDiscoverServices(
1503
+ peripheral,
1504
+ deviceId,
1505
+ webContents
1506
+ );
1507
+ deviceCharacteristics.set(deviceId, characteristics);
1508
+ logger?.info('[NobleBLE] Device ready for communication:', deviceId);
1509
+ } catch (setupError) {
1510
+ // The caller re-arms the timer only on success, so a left-up link strands.
1511
+ logger?.error('[NobleBLE] Connection setup failed on kept-alive link:', setupError);
1512
+ await disconnectDevice(deviceId).catch(() => undefined);
1513
+ throw setupError;
1514
+ }
1279
1515
  return;
1280
1516
  }
1281
1517
 
1282
1518
  return new Promise((resolve, reject) => {
1519
+ let connectionTimedOut = false;
1283
1520
  const timeout = setTimeout(() => {
1521
+ connectionTimedOut = true;
1284
1522
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
1285
- }, CONNECTION_TIMEOUT);
1523
+ }, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
1286
1524
 
1287
1525
  // TypeScript type assertion - peripheral is guaranteed to be defined at this point
1288
1526
  const connectedPeripheral = peripheral as Peripheral;
1289
1527
  connectedPeripheral.connect(async (error: Error | undefined) => {
1290
1528
  clearTimeout(timeout);
1291
1529
 
1530
+ // Noble may invoke the callback after the SDK timed out and released the request.
1531
+ // Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
1532
+ if (connectionTimedOut) {
1533
+ if (!error) {
1534
+ try {
1535
+ connectedPeripheral.disconnect(() => undefined);
1536
+ } catch {
1537
+ // Best-effort cleanup only.
1538
+ }
1539
+ }
1540
+ return;
1541
+ }
1542
+
1292
1543
  if (error) {
1293
1544
  logger?.error('[NobleBLE] Connection failed:', error);
1294
1545
  reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
@@ -1325,20 +1576,21 @@ async function disconnectDevice(deviceId: string): Promise<void> {
1325
1576
  return;
1326
1577
  }
1327
1578
 
1328
- return new Promise<void>(resolve => {
1329
- // Remove disconnect listener to avoid triggering handleDeviceDisconnect
1330
- peripheral.removeAllListeners('disconnect');
1331
-
1332
- peripheral.disconnect(() => {
1333
- // Clean up device state using unified function
1334
- cleanupDevice(deviceId, undefined, {
1335
- cleanupConnection: true,
1336
- sendDisconnectEvent: false,
1337
- cancelOperations: true,
1338
- reason: 'manual-disconnect',
1339
- });
1340
- resolve();
1341
- });
1579
+ const disconnectEntry = deviceDisconnectListeners.get(deviceId);
1580
+ if (disconnectEntry) {
1581
+ disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
1582
+ deviceDisconnectListeners.delete(deviceId);
1583
+ }
1584
+
1585
+ await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1586
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1587
+ timeoutBehavior: 'resolve',
1588
+ });
1589
+ cleanupDevice(deviceId, undefined, {
1590
+ cleanupConnection: true,
1591
+ sendDisconnectEvent: false,
1592
+ cancelOperations: true,
1593
+ reason: 'manual-disconnect',
1342
1594
  });
1343
1595
  }
1344
1596
 
@@ -1359,17 +1611,11 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
1359
1611
  subscriptionOperations.set(deviceId, 'unsubscribing');
1360
1612
 
1361
1613
  try {
1362
- await new Promise<void>((resolve, reject) => {
1363
- notifyCharacteristic.unsubscribe((error: Error | undefined) => {
1364
- if (error) {
1365
- logger?.error('[NobleBLE] Notification unsubscription failed:', error);
1366
- reject(error);
1367
- } else {
1368
- logger?.info('[NobleBLE] Notification unsubscription successful');
1369
- resolve();
1370
- }
1371
- });
1614
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1615
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1616
+ timeoutBehavior: 'resolve',
1372
1617
  });
1618
+ logger?.info('[NobleBLE] Notification unsubscription completed');
1373
1619
 
1374
1620
  // Remove all listeners and clear subscription status
1375
1621
  notifyCharacteristic.removeAllListeners('data');
@@ -1462,19 +1708,13 @@ async function subscribeNotifications(
1462
1708
  notifyCharacteristic: Characteristic
1463
1709
  ): Promise<void> {
1464
1710
  // Listeners already cleaned up above, no need to repeat
1465
- await new Promise<void>(resolve => {
1466
- notifyCharacteristic.unsubscribe(() => {
1467
- resolve();
1468
- });
1711
+ await runBleCallbackOperation(callback => notifyCharacteristic.unsubscribe(callback), {
1712
+ timeoutMs: BLE_CLEANUP_TIMEOUT,
1713
+ timeoutBehavior: 'resolve',
1469
1714
  });
1470
- await new Promise<void>((resolve, reject) => {
1471
- notifyCharacteristic.subscribe((error?: Error) => {
1472
- if (error) {
1473
- reject(error);
1474
- return;
1475
- }
1476
- resolve();
1477
- });
1715
+ await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
1716
+ timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
1717
+ timeoutBehavior: 'reject',
1478
1718
  });
1479
1719
 
1480
1720
  notifyCharacteristic.on('data', (data: Buffer) => {
@@ -1506,12 +1746,18 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1506
1746
 
1507
1747
  // @ts-ignore – electron is only available at runtime
1508
1748
  // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
1509
- const { ipcMain } = require('electron');
1749
+ const { ipcMain } = require('electron') as { ipcMain: IpcMain };
1750
+
1751
+ // Electron throws on duplicate channels and setup re-runs on soft restart.
1752
+ const handle: IpcMain['handle'] = (channel, listener) => {
1753
+ ipcMain.removeHandler(channel);
1754
+ ipcMain.handle(channel, listener);
1755
+ };
1510
1756
 
1511
1757
  safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
1512
1758
 
1513
1759
  // Handle enumerate request
1514
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1760
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
1515
1761
  try {
1516
1762
  const devices = await enumerateDevices();
1517
1763
  safeLog(logger, 'debug', 'Enumeration completed', {
@@ -1526,18 +1772,18 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1526
1772
  });
1527
1773
 
1528
1774
  // Handle stop scan request
1529
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, async () => {
1775
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_STOP_SCAN, async () => {
1530
1776
  await stopScanning();
1531
1777
  });
1532
1778
 
1533
1779
  // Handle get device request
1534
- ipcMain.handle(
1780
+ handle(
1535
1781
  EOneKeyBleMessageKeys.NOBLE_BLE_GET_DEVICE,
1536
1782
  (_event: IpcMainInvokeEvent, deviceId: string) => getDevice(deviceId)
1537
1783
  );
1538
1784
 
1539
1785
  // Handle connect request
1540
- ipcMain.handle(
1786
+ handle(
1541
1787
  EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT,
1542
1788
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1543
1789
  logger?.info('[NobleBLE] IPC CONNECT request received:', {
@@ -1546,12 +1792,37 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1546
1792
  hasCharacteristics: deviceCharacteristics.has(deviceId),
1547
1793
  totalConnectedDevices: connectedDevices.size,
1548
1794
  });
1549
- await connectDevice(deviceId, webContents);
1795
+ // Must not fire mid-connect (pairing can take ~30s).
1796
+ clearIdleDisconnect(deviceId);
1797
+ // A fired timer must settle first, or the fast path returns a dying link.
1798
+ await awaitIdleDisconnect(deviceId);
1799
+ try {
1800
+ await connectDevice(deviceId, webContents);
1801
+ } finally {
1802
+ // This timer is the only thing that frees a kept-alive link.
1803
+ if (connectedDevices.has(deviceId)) {
1804
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1805
+ }
1806
+ }
1807
+ }
1808
+ );
1809
+
1810
+ // Logical release: start the idle countdown, keep the link for the next call.
1811
+ handle(
1812
+ EOneKeyBleMessageKeys.NOBLE_BLE_RELEASE,
1813
+ (_event: IpcMainInvokeEvent, deviceId: string, keepSession?: boolean) => {
1814
+ if (!connectedDevices.has(deviceId)) return;
1815
+ // Mid-flow caller will be back; the short window would cut an update.
1816
+ if (keepSession) {
1817
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1818
+ return;
1819
+ }
1820
+ armIdleDisconnect(deviceId);
1550
1821
  }
1551
1822
  );
1552
1823
 
1553
1824
  // Handle disconnect request
1554
- ipcMain.handle(
1825
+ handle(
1555
1826
  EOneKeyBleMessageKeys.NOBLE_BLE_DISCONNECT,
1556
1827
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1557
1828
  await disconnectDevice(deviceId);
@@ -1559,34 +1830,42 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1559
1830
  );
1560
1831
 
1561
1832
  // Handle write request
1562
- ipcMain.handle(
1833
+ handle(
1563
1834
  EOneKeyBleMessageKeys.NOBLE_BLE_WRITE,
1564
- async (_event: IpcMainInvokeEvent, deviceId: string, hexData: string) => {
1565
- await transmitHexDataToDevice(deviceId, hexData);
1835
+ async (
1836
+ _event: IpcMainInvokeEvent,
1837
+ deviceId: string,
1838
+ hexData: string,
1839
+ options?: NobleBleWriteOptions
1840
+ ) => {
1841
+ await transmitHexDataToDevice(deviceId, hexData, options);
1566
1842
  }
1567
1843
  );
1568
1844
 
1569
1845
  // Handle subscribe request
1570
- ipcMain.handle(
1846
+ handle(
1571
1847
  EOneKeyBleMessageKeys.NOBLE_BLE_SUBSCRIBE,
1572
1848
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1573
1849
  await subscribeNotifications(deviceId, (data: string) => {
1574
1850
  // Send data back to renderer process
1575
1851
  webContents.send(EOneKeyBleMessageKeys.NOBLE_BLE_NOTIFICATION, deviceId, data);
1576
1852
  });
1853
+ // Still acquiring (in flight) — busy backstop, not the idle clock.
1854
+ armIdleDisconnect(deviceId, BLE_BUSY_BACKSTOP_MS, 'busy-backstop');
1577
1855
  }
1578
1856
  );
1579
1857
 
1580
1858
  // Handle unsubscribe request
1581
- ipcMain.handle(
1859
+ handle(
1582
1860
  EOneKeyBleMessageKeys.NOBLE_BLE_UNSUBSCRIBE,
1583
1861
  async (_event: IpcMainInvokeEvent, deviceId: string) => {
1584
1862
  await unsubscribeNotifications(deviceId);
1863
+ armIdleDisconnect(deviceId);
1585
1864
  }
1586
1865
  );
1587
1866
 
1588
1867
  // Handle cancel pairing: cleanup all connected devices
1589
- ipcMain.handle(EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, async () => {
1868
+ handle(EOneKeyBleMessageKeys.NOBLE_BLE_CANCEL_PAIRING, async () => {
1590
1869
  const deviceIds = Array.from(connectedDevices.keys());
1591
1870
  logger?.info('[NobleBLE] Cancel pairing invoked', {
1592
1871
  platform: process.platform,
@@ -1607,7 +1886,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1607
1886
  });
1608
1887
 
1609
1888
  // Handle Bluetooth availability check request
1610
- ipcMain.handle(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, async () => {
1889
+ handle(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK, async () => {
1611
1890
  try {
1612
1891
  const bluetoothStatus = await checkBluetoothAvailability();
1613
1892
  safeLog(logger, 'info', 'Bluetooth availability check completed:', bluetoothStatus);
@@ -1626,32 +1905,24 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
1626
1905
  // Cleanup on app quit
1627
1906
  webContents.on('destroyed', () => {
1628
1907
  safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
1908
+ (async () => {
1909
+ const deviceIds = Array.from(connectedDevices.keys());
1910
+ for (const deviceId of deviceIds) {
1911
+ await unsubscribeNotifications(deviceId).catch(() => undefined);
1912
+ await disconnectDevice(deviceId).catch(() => undefined);
1913
+ }
1629
1914
 
1630
- // 1. Clean up all connected devices (unified cleanup, avoid duplicates)
1631
- const deviceIds = Array.from(connectedDevices.keys());
1632
- deviceIds.forEach(deviceId => {
1633
- cleanupDevice(deviceId, undefined, {
1634
- cleanupConnection: true,
1635
- sendDisconnectEvent: false,
1636
- cancelOperations: true,
1637
- reason: 'app-quit',
1638
- });
1915
+ await stopScanning().catch(() => undefined);
1916
+ if (noble && persistentStateListener) {
1917
+ noble.removeListener('stateChange', persistentStateListener);
1918
+ persistentStateListener = null;
1919
+ }
1920
+ cleanupNobleListeners();
1921
+ discoveredDevices.clear();
1922
+ safeLog(logger, 'info', 'Noble BLE cleanup completed');
1923
+ })().catch(error => {
1924
+ safeLog(logger, 'error', 'Noble BLE cleanup failed', error);
1639
1925
  });
1640
-
1641
- // 2. Stop scanning
1642
- stopScanning();
1643
-
1644
- // 3. Clean up Noble listeners
1645
- if (noble && persistentStateListener) {
1646
- noble.removeListener('stateChange', persistentStateListener);
1647
- persistentStateListener = null;
1648
- }
1649
- cleanupNobleListeners();
1650
-
1651
- // 4. Clear discovered devices cache
1652
- discoveredDevices.clear();
1653
-
1654
- safeLog(logger, 'info', 'Noble BLE cleanup completed');
1655
1926
  });
1656
1927
 
1657
1928
  safeLog(logger, 'info', 'Noble BLE IPC handlers setup completed');