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