@onekeyfe/hd-transport-electron 1.2.2-alpha.9 → 1.2.3-alpha.2
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/{index-00297050.js → index-985e2bf3.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-95c5cf0d.js → noble-ble-handler-263d4736.js} +374 -88
- 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__/noble-ble-handler.test.ts +653 -18
- package/src/index.ts +14 -1
- package/src/noble-ble-handler.ts +438 -109
- 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,11 +10,13 @@ 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,
|
|
19
21
|
isPro2FamilyBleName,
|
|
20
22
|
matchesKnownBleUuid,
|
|
@@ -26,18 +28,102 @@ import { resolveBlePacketCapacity, resolveNobleAttMtu } from './ble-packet-capac
|
|
|
26
28
|
import { safeLog } from './types/noble-extended';
|
|
27
29
|
import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
|
|
28
30
|
import {
|
|
29
|
-
|
|
31
|
+
NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
|
|
30
32
|
NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
|
|
31
33
|
} from './noble-ble-timeouts';
|
|
32
34
|
|
|
33
35
|
import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
|
|
34
36
|
import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
|
|
35
|
-
import type { NobleBleWriteOptions } from './types/desktop-api';
|
|
37
|
+
import type { NobleBleIpcErrorResponse, NobleBleWriteOptions } from './types/desktop-api';
|
|
36
38
|
import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
|
|
37
39
|
|
|
38
40
|
// Noble will be dynamically imported to avoid bundling issues
|
|
39
41
|
let noble: NobleModule | null = null;
|
|
40
42
|
let logger: Logger | null = null;
|
|
43
|
+
let disposing = false;
|
|
44
|
+
let nativeReleased = false;
|
|
45
|
+
let disposePromise: Promise<void> | undefined;
|
|
46
|
+
let windowCleanup: Promise<void> | undefined;
|
|
47
|
+
let removeIpcHandlers: (() => void) | undefined;
|
|
48
|
+
const pendingCancellations = new Set<() => void>();
|
|
49
|
+
const pendingEnumerations = new Set<() => Promise<void>>();
|
|
50
|
+
const nativeConnections = new Set<{ cancel: () => void; settled: Promise<void> }>();
|
|
51
|
+
|
|
52
|
+
function trackNativeConnection(operation: Promise<unknown>, cancel: () => void): void {
|
|
53
|
+
const connection = {
|
|
54
|
+
cancel,
|
|
55
|
+
settled: operation.then(
|
|
56
|
+
() => undefined,
|
|
57
|
+
() => undefined
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
nativeConnections.add(connection);
|
|
61
|
+
connection.settled = connection.settled.finally(() => nativeConnections.delete(connection));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertBleActive(): void {
|
|
65
|
+
if (disposing) {
|
|
66
|
+
throw ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type NobleBleNativeError = Error & {
|
|
71
|
+
nativeErrorCode?: number;
|
|
72
|
+
nativeErrorDomain?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function createNobleBleConnectionError(error: NobleBleNativeError, messagePrefix = '') {
|
|
76
|
+
const errorMessage = error.message;
|
|
77
|
+
const isInvalidMacOsBond =
|
|
78
|
+
(error.nativeErrorCode === 14 && error.nativeErrorDomain === 'CBErrorDomain') ||
|
|
79
|
+
(error.nativeErrorCode === 15 && error.nativeErrorDomain === 'CBATTErrorDomain');
|
|
80
|
+
if (isInvalidMacOsBond) {
|
|
81
|
+
const nativeErrorMessage = `${messagePrefix}${errorMessage}`;
|
|
82
|
+
return ERRORS.TypedError(
|
|
83
|
+
HardwareErrorCode.BleBondInvalid,
|
|
84
|
+
`${HardwareErrorCodeMessage[HardwareErrorCode.BleBondInvalid]} (${nativeErrorMessage})`,
|
|
85
|
+
{
|
|
86
|
+
nativeErrorMessage,
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return ERRORS.TypedError(HardwareErrorCode.BleConnectedError, `${messagePrefix}${errorMessage}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createNobleBleIpcErrorResponse(error: unknown): NobleBleIpcErrorResponse {
|
|
95
|
+
const candidate = error as {
|
|
96
|
+
errorCode?: unknown;
|
|
97
|
+
message?: unknown;
|
|
98
|
+
name?: unknown;
|
|
99
|
+
params?: unknown;
|
|
100
|
+
};
|
|
101
|
+
const errorCode =
|
|
102
|
+
typeof candidate?.errorCode === 'number' ? candidate.errorCode : HardwareErrorCode.UnknownError;
|
|
103
|
+
const message =
|
|
104
|
+
typeof candidate?.message === 'string' ? candidate.message : String(error ?? 'Unknown error');
|
|
105
|
+
const name = typeof candidate?.name === 'string' ? candidate.name : 'Error';
|
|
106
|
+
let params: unknown;
|
|
107
|
+
if (candidate?.params !== undefined) {
|
|
108
|
+
try {
|
|
109
|
+
const serializedParams = JSON.stringify(candidate.params);
|
|
110
|
+
params = serializedParams === undefined ? undefined : JSON.parse(serializedParams);
|
|
111
|
+
} catch {
|
|
112
|
+
params = undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
type: 'NobleBleIpcError',
|
|
118
|
+
success: false,
|
|
119
|
+
error: {
|
|
120
|
+
name,
|
|
121
|
+
message,
|
|
122
|
+
errorCode,
|
|
123
|
+
...(params !== undefined ? { params } : {}),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
41
127
|
|
|
42
128
|
// Bluetooth state management
|
|
43
129
|
const bluetoothState: {
|
|
@@ -57,6 +143,102 @@ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null
|
|
|
57
143
|
// Device cache and connection state
|
|
58
144
|
const discoveredDevices = new Map<string, Peripheral>();
|
|
59
145
|
const connectedDevices = new Map<string, Peripheral>();
|
|
146
|
+
type PendingDeviceConnection = {
|
|
147
|
+
peripheral: Peripheral;
|
|
148
|
+
generation: number;
|
|
149
|
+
cancel: (error: unknown) => void;
|
|
150
|
+
};
|
|
151
|
+
const connectingDevices = new Map<string, PendingDeviceConnection>();
|
|
152
|
+
let nextConnectionGeneration = 0;
|
|
153
|
+
|
|
154
|
+
function connectPeripheralWithCancellation(
|
|
155
|
+
peripheral: Peripheral,
|
|
156
|
+
deviceId: string,
|
|
157
|
+
messagePrefix = ''
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
assertBleActive();
|
|
160
|
+
return new Promise<void>((resolve, reject) => {
|
|
161
|
+
const generation = ++nextConnectionGeneration;
|
|
162
|
+
let settled = false;
|
|
163
|
+
const settle = (settler: () => void): boolean => {
|
|
164
|
+
if (settled) return false;
|
|
165
|
+
settled = true;
|
|
166
|
+
if (connectingDevices.get(deviceId)?.generation === generation) {
|
|
167
|
+
connectingDevices.delete(deviceId);
|
|
168
|
+
}
|
|
169
|
+
settler();
|
|
170
|
+
return true;
|
|
171
|
+
};
|
|
172
|
+
const pendingConnection: PendingDeviceConnection = {
|
|
173
|
+
peripheral,
|
|
174
|
+
generation,
|
|
175
|
+
cancel: error => {
|
|
176
|
+
settle(() => reject(error));
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
connectingDevices
|
|
180
|
+
.get(deviceId)
|
|
181
|
+
?.cancel(
|
|
182
|
+
ERRORS.TypedError(
|
|
183
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
184
|
+
`Device ${deviceId} connect attempt was superseded`
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
connectingDevices.set(deviceId, pendingConnection);
|
|
188
|
+
|
|
189
|
+
let completeNative!: () => void;
|
|
190
|
+
trackNativeConnection(
|
|
191
|
+
new Promise<void>(resolve => {
|
|
192
|
+
completeNative = resolve;
|
|
193
|
+
}),
|
|
194
|
+
() => {
|
|
195
|
+
if (typeof peripheral.cancelConnect === 'function') peripheral.cancelConnect();
|
|
196
|
+
else noble?.cancelConnect?.(deviceId);
|
|
197
|
+
}
|
|
198
|
+
);
|
|
199
|
+
const onConnect = (error?: Error | null) => {
|
|
200
|
+
if (nativeReleased) {
|
|
201
|
+
completeNative();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (error) {
|
|
205
|
+
settle(() => reject(createNobleBleConnectionError(error, messagePrefix)));
|
|
206
|
+
completeNative();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
!settle(() => {
|
|
212
|
+
connectedDevices.set(deviceId, peripheral);
|
|
213
|
+
resolve();
|
|
214
|
+
})
|
|
215
|
+
) {
|
|
216
|
+
const activeConnection = connectingDevices.get(deviceId);
|
|
217
|
+
if (!activeConnection || activeConnection.peripheral !== peripheral) {
|
|
218
|
+
try {
|
|
219
|
+
peripheral.removeAllListeners('disconnect');
|
|
220
|
+
runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
221
|
+
timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
|
|
222
|
+
timeoutBehavior: 'resolve',
|
|
223
|
+
})
|
|
224
|
+
.catch(() => undefined)
|
|
225
|
+
.finally(completeNative);
|
|
226
|
+
return;
|
|
227
|
+
} catch {
|
|
228
|
+
// Best-effort cleanup for a stale native callback.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
completeNative();
|
|
233
|
+
};
|
|
234
|
+
try {
|
|
235
|
+
peripheral.connect(onConnect);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
completeNative();
|
|
238
|
+
settle(() => reject(error));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
60
242
|
const pairedDevices = new Set<string>(); // Windows BLE device pairing status tracking
|
|
61
243
|
const deviceCharacteristics = new Map<string, CharacteristicPair>();
|
|
62
244
|
const notificationCallbacks = new Map<string, (data: string) => void>();
|
|
@@ -261,6 +443,7 @@ function updateBluetoothState(state: string): void {
|
|
|
261
443
|
|
|
262
444
|
// Initialize Noble
|
|
263
445
|
async function initializeNoble(): Promise<void> {
|
|
446
|
+
assertBleActive();
|
|
264
447
|
if (noble) return;
|
|
265
448
|
|
|
266
449
|
try {
|
|
@@ -286,12 +469,14 @@ async function initializeNoble(): Promise<void> {
|
|
|
286
469
|
}
|
|
287
470
|
|
|
288
471
|
const timeout = setTimeout(() => {
|
|
472
|
+
cleanup();
|
|
289
473
|
reject(
|
|
290
474
|
ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
|
|
291
475
|
);
|
|
292
476
|
}, BLUETOOTH_INIT_TIMEOUT);
|
|
293
477
|
|
|
294
478
|
const cleanup = () => {
|
|
479
|
+
pendingCancellations.delete(cancel);
|
|
295
480
|
clearTimeout(timeout);
|
|
296
481
|
if (noble) {
|
|
297
482
|
noble.removeListener('stateChange', onStateChange);
|
|
@@ -316,9 +501,17 @@ async function initializeNoble(): Promise<void> {
|
|
|
316
501
|
}
|
|
317
502
|
};
|
|
318
503
|
|
|
504
|
+
const cancel = () => {
|
|
505
|
+
cleanup();
|
|
506
|
+
reject(
|
|
507
|
+
ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
|
|
508
|
+
);
|
|
509
|
+
};
|
|
510
|
+
pendingCancellations.add(cancel);
|
|
319
511
|
noble.on('stateChange', onStateChange);
|
|
320
512
|
});
|
|
321
513
|
|
|
514
|
+
assertBleActive();
|
|
322
515
|
// Set up device discovery
|
|
323
516
|
if (!persistentDiscoverListener) {
|
|
324
517
|
persistentDiscoverListener = (peripheral: Peripheral) => {
|
|
@@ -403,6 +596,7 @@ function armIdleDisconnect(
|
|
|
403
596
|
reason: 'idle' | 'busy-backstop' = 'idle'
|
|
404
597
|
): void {
|
|
405
598
|
clearIdleDisconnect(deviceId);
|
|
599
|
+
if (disposing) return;
|
|
406
600
|
idleDisconnectTimers.set(
|
|
407
601
|
deviceId,
|
|
408
602
|
setTimeout(() => {
|
|
@@ -485,6 +679,14 @@ function cleanupDevice(
|
|
|
485
679
|
|
|
486
680
|
// 1. Clean up connection state
|
|
487
681
|
if (cleanupConnection) {
|
|
682
|
+
connectingDevices
|
|
683
|
+
.get(deviceId)
|
|
684
|
+
?.cancel(
|
|
685
|
+
ERRORS.TypedError(
|
|
686
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
687
|
+
`Device ${deviceId} disconnected while connecting`
|
|
688
|
+
)
|
|
689
|
+
);
|
|
488
690
|
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
489
691
|
if (disconnectEntry) {
|
|
490
692
|
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
@@ -594,6 +796,7 @@ async function writeCharacteristicWithoutResponse(
|
|
|
594
796
|
writeCharacteristic: Characteristic,
|
|
595
797
|
buffer: Buffer
|
|
596
798
|
): Promise<void> {
|
|
799
|
+
assertBleActive();
|
|
597
800
|
return new Promise((resolve, reject) => {
|
|
598
801
|
writeCharacteristic.write(buffer, true, (error?: Error) => {
|
|
599
802
|
if (error) {
|
|
@@ -878,6 +1081,7 @@ async function performTargetedScan(
|
|
|
878
1081
|
targetDeviceId: string,
|
|
879
1082
|
timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
|
|
880
1083
|
): Promise<Peripheral | null> {
|
|
1084
|
+
assertBleActive();
|
|
881
1085
|
if (!noble) {
|
|
882
1086
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
883
1087
|
}
|
|
@@ -893,6 +1097,7 @@ async function performTargetedScan(
|
|
|
893
1097
|
const finish = async (peripheral: Peripheral | null, error?: Error) => {
|
|
894
1098
|
if (settled) return;
|
|
895
1099
|
settled = true;
|
|
1100
|
+
pendingCancellations.delete(cancel);
|
|
896
1101
|
if (timeoutId) clearTimeout(timeoutId);
|
|
897
1102
|
nobleInstance.removeListener('discover', onDiscover);
|
|
898
1103
|
await waitForNobleScanStop(nobleInstance);
|
|
@@ -902,6 +1107,7 @@ async function performTargetedScan(
|
|
|
902
1107
|
reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
|
|
903
1108
|
return;
|
|
904
1109
|
}
|
|
1110
|
+
assertBleActive();
|
|
905
1111
|
if (peripheral) {
|
|
906
1112
|
discoveredDevices.set(peripheral.id, peripheral);
|
|
907
1113
|
}
|
|
@@ -924,6 +1130,11 @@ async function performTargetedScan(
|
|
|
924
1130
|
finish(null).catch(reject);
|
|
925
1131
|
}, timeoutMs);
|
|
926
1132
|
|
|
1133
|
+
const cancel = () => {
|
|
1134
|
+
finish(null, new Error('Noble BLE is shutting down')).catch(reject);
|
|
1135
|
+
};
|
|
1136
|
+
pendingCancellations.add(cancel);
|
|
1137
|
+
|
|
927
1138
|
// Add local listener for this scan
|
|
928
1139
|
nobleInstance.on('discover', onDiscover);
|
|
929
1140
|
|
|
@@ -940,7 +1151,7 @@ async function performTargetedScan(
|
|
|
940
1151
|
}
|
|
941
1152
|
|
|
942
1153
|
// Enumerate devices
|
|
943
|
-
async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
1154
|
+
async function enumerateDevices(isWindowDestroyed: () => boolean): Promise<DeviceInfo[]> {
|
|
944
1155
|
if (!noble) {
|
|
945
1156
|
await initializeNoble();
|
|
946
1157
|
}
|
|
@@ -949,6 +1160,8 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
949
1160
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
950
1161
|
}
|
|
951
1162
|
|
|
1163
|
+
assertBleActive();
|
|
1164
|
+
if (isWindowDestroyed()) return [];
|
|
952
1165
|
// Capture noble reference for use in closures (TypeScript narrowing)
|
|
953
1166
|
const nobleInstance = noble;
|
|
954
1167
|
|
|
@@ -963,15 +1176,24 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
963
1176
|
|
|
964
1177
|
return new Promise((resolve, reject) => {
|
|
965
1178
|
const devices: DeviceInfo[] = [];
|
|
1179
|
+
let settled = false;
|
|
966
1180
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
1181
|
+
let cleanupPromise: Promise<void> | undefined;
|
|
967
1182
|
|
|
968
1183
|
// Cleanup function: clears timers and waits until Noble confirms scanning
|
|
969
1184
|
// has stopped. Resolving enumerate before this callback creates a race with
|
|
970
1185
|
// an immediately-following connection attempt.
|
|
971
|
-
const cleanup =
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1186
|
+
const cleanup = () => {
|
|
1187
|
+
if (!cleanupPromise) {
|
|
1188
|
+
settled = true;
|
|
1189
|
+
pendingCancellations.delete(cancel);
|
|
1190
|
+
clearTimeout(timeoutId);
|
|
1191
|
+
if (intervalId) clearInterval(intervalId);
|
|
1192
|
+
cleanupPromise = waitForNobleScanStop(nobleInstance).finally(() => {
|
|
1193
|
+
pendingEnumerations.delete(cancel);
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
return cleanupPromise;
|
|
975
1197
|
};
|
|
976
1198
|
|
|
977
1199
|
// Collect discovered devices into the devices array
|
|
@@ -992,6 +1214,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
992
1214
|
|
|
993
1215
|
// Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
|
|
994
1216
|
const timeoutId = setTimeout(async () => {
|
|
1217
|
+
if (settled) return;
|
|
995
1218
|
// Final collection before resolving — catches devices discovered near the deadline
|
|
996
1219
|
checkDevices();
|
|
997
1220
|
await cleanup();
|
|
@@ -999,11 +1222,16 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
999
1222
|
resolve(devices);
|
|
1000
1223
|
}, DEVICE_SCAN_TIMEOUT);
|
|
1001
1224
|
|
|
1225
|
+
const cancel = () => cleanup().then(() => resolve([]), reject);
|
|
1226
|
+
pendingCancellations.add(cancel);
|
|
1227
|
+
pendingEnumerations.add(cancel);
|
|
1228
|
+
|
|
1002
1229
|
// Start scanning without a service UUID filter so Pro2 advertisements with
|
|
1003
1230
|
// short vendor UUIDs can be found. Repeated advertisements are required when
|
|
1004
1231
|
// the local name arrives in a later scan response; discoveredDevices handles deduplication.
|
|
1005
1232
|
logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
|
|
1006
1233
|
nobleInstance.startScanning([], true, async (error?: Error) => {
|
|
1234
|
+
if (settled) return;
|
|
1007
1235
|
if (error) {
|
|
1008
1236
|
await cleanup();
|
|
1009
1237
|
logger?.error('[NobleBLE] Failed to start scanning:', error);
|
|
@@ -1021,7 +1249,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1021
1249
|
|
|
1022
1250
|
// Stop scanning
|
|
1023
1251
|
async function stopScanning(): Promise<void> {
|
|
1024
|
-
if (!noble) return;
|
|
1252
|
+
if (!noble || nativeReleased) return;
|
|
1025
1253
|
const nobleInstance = noble;
|
|
1026
1254
|
await waitForNobleScanStop(nobleInstance);
|
|
1027
1255
|
logger?.info('[NobleBLE] Scanning stopped');
|
|
@@ -1094,13 +1322,17 @@ function getDevice(deviceId: string): DeviceInfo | null {
|
|
|
1094
1322
|
async function discoverServicesAndCharacteristics(
|
|
1095
1323
|
peripheral: Peripheral
|
|
1096
1324
|
): Promise<CharacteristicPair> {
|
|
1325
|
+
assertBleActive();
|
|
1097
1326
|
// Cleanup resources - will be set up and cleaned in try/finally
|
|
1098
1327
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
1099
1328
|
let onDisconnect: (() => void) | undefined;
|
|
1100
1329
|
|
|
1101
1330
|
const cleanup = () => {
|
|
1102
1331
|
if (timeoutId) clearTimeout(timeoutId);
|
|
1103
|
-
if (onDisconnect)
|
|
1332
|
+
if (onDisconnect) {
|
|
1333
|
+
peripheral.removeListener('disconnect', onDisconnect);
|
|
1334
|
+
pendingCancellations.delete(onDisconnect);
|
|
1335
|
+
}
|
|
1104
1336
|
};
|
|
1105
1337
|
|
|
1106
1338
|
// Racing promises for timeout and disconnect
|
|
@@ -1121,6 +1353,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
1121
1353
|
)
|
|
1122
1354
|
);
|
|
1123
1355
|
};
|
|
1356
|
+
pendingCancellations.add(onDisconnect);
|
|
1124
1357
|
peripheral.once('disconnect', onDisconnect);
|
|
1125
1358
|
});
|
|
1126
1359
|
|
|
@@ -1146,6 +1379,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
1146
1379
|
});
|
|
1147
1380
|
});
|
|
1148
1381
|
|
|
1382
|
+
assertBleActive();
|
|
1149
1383
|
if (!services || services.length === 0) {
|
|
1150
1384
|
throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
|
|
1151
1385
|
}
|
|
@@ -1252,12 +1486,8 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
|
|
|
1252
1486
|
}
|
|
1253
1487
|
|
|
1254
1488
|
// Step 3: Re-establish connection
|
|
1255
|
-
await
|
|
1256
|
-
timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
|
|
1257
|
-
timeoutBehavior: 'reject',
|
|
1258
|
-
});
|
|
1489
|
+
await connectPeripheralWithCancellation(peripheral, deviceId);
|
|
1259
1490
|
logger?.info('[NobleBLE] Force reconnect successful');
|
|
1260
|
-
connectedDevices.set(deviceId, peripheral);
|
|
1261
1491
|
|
|
1262
1492
|
// Wait for connection to stabilize
|
|
1263
1493
|
await wait(500);
|
|
@@ -1293,21 +1523,11 @@ async function freshScanAndDiscover(
|
|
|
1293
1523
|
discoveredDevices.set(deviceId, freshPeripheral);
|
|
1294
1524
|
|
|
1295
1525
|
// Connect to fresh peripheral
|
|
1296
|
-
await
|
|
1297
|
-
freshPeripheral
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
HardwareErrorCode.BleConnectedError,
|
|
1302
|
-
`Fresh peripheral connection failed: ${error.message}`
|
|
1303
|
-
)
|
|
1304
|
-
);
|
|
1305
|
-
} else {
|
|
1306
|
-
connectedDevices.set(deviceId, freshPeripheral);
|
|
1307
|
-
resolve();
|
|
1308
|
-
}
|
|
1309
|
-
});
|
|
1310
|
-
});
|
|
1526
|
+
await connectPeripheralWithCancellation(
|
|
1527
|
+
freshPeripheral,
|
|
1528
|
+
deviceId,
|
|
1529
|
+
'Fresh peripheral connection failed: '
|
|
1530
|
+
);
|
|
1311
1531
|
|
|
1312
1532
|
// Setup disconnect listener for fresh peripheral
|
|
1313
1533
|
setupDisconnectListener(freshPeripheral, deviceId, webContents);
|
|
@@ -1379,6 +1599,7 @@ async function discoverServicesAndCharacteristicsWithRetry(
|
|
|
1379
1599
|
minTimeout: 500,
|
|
1380
1600
|
maxTimeout: 3000,
|
|
1381
1601
|
onFailedAttempt: error => {
|
|
1602
|
+
assertBleActive();
|
|
1382
1603
|
// This runs after each failed attempt
|
|
1383
1604
|
logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
|
|
1384
1605
|
message: error.message,
|
|
@@ -1414,10 +1635,17 @@ async function setupConnectionAndDiscoverServices(
|
|
|
1414
1635
|
try {
|
|
1415
1636
|
await forceReconnectPeripheral(peripheral, deviceId);
|
|
1416
1637
|
} catch (resetError) {
|
|
1638
|
+
if (
|
|
1639
|
+
isBleStaleBondHardwareError(resetError) ||
|
|
1640
|
+
(resetError as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected
|
|
1641
|
+
) {
|
|
1642
|
+
throw resetError;
|
|
1643
|
+
}
|
|
1417
1644
|
// A failed reset must not abort the attempt: discovery on the existing
|
|
1418
1645
|
// link, then the fresh-scan fallback, still have a chance to recover.
|
|
1419
1646
|
logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
|
|
1420
1647
|
}
|
|
1648
|
+
assertBleActive();
|
|
1421
1649
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1422
1650
|
|
|
1423
1651
|
try {
|
|
@@ -1460,6 +1688,7 @@ const directConnectCooldownUntil = new Map<string, number>();
|
|
|
1460
1688
|
* Ported from the Trezor connector, where it is field-proven.
|
|
1461
1689
|
*/
|
|
1462
1690
|
async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
|
|
1691
|
+
assertBleActive();
|
|
1463
1692
|
const nobleInstance = noble as
|
|
1464
1693
|
| (typeof noble & {
|
|
1465
1694
|
connectAsync?: (id: string) => Promise<Peripheral | undefined>;
|
|
@@ -1470,34 +1699,42 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
|
|
|
1470
1699
|
const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
|
|
1471
1700
|
if (Date.now() < cooldownUntil) return undefined;
|
|
1472
1701
|
|
|
1702
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1703
|
+
let cancel: (() => void) | undefined;
|
|
1704
|
+
let abandoned = false;
|
|
1473
1705
|
try {
|
|
1474
1706
|
// The late-orphan guard must attach to THIS pending connect.
|
|
1475
1707
|
const directPromise = nobleInstance.connectAsync(deviceId);
|
|
1708
|
+
trackNativeConnection(
|
|
1709
|
+
directPromise.then(async late => {
|
|
1710
|
+
if ((!abandoned && !disposing) || nativeReleased) return;
|
|
1711
|
+
const peripheral = late ?? discoveredDevices.get(deviceId);
|
|
1712
|
+
if (peripheral?.state === 'connected' && !connectedDevices.has(deviceId)) {
|
|
1713
|
+
peripheral.removeAllListeners('disconnect');
|
|
1714
|
+
await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
1715
|
+
timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
|
|
1716
|
+
timeoutBehavior: 'resolve',
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
}),
|
|
1720
|
+
() => nobleInstance.cancelConnect?.(deviceId)
|
|
1721
|
+
);
|
|
1476
1722
|
const raced = await Promise.race([
|
|
1477
1723
|
directPromise,
|
|
1478
1724
|
new Promise<'timeout'>(resolve => {
|
|
1479
|
-
|
|
1725
|
+
cancel = () => {
|
|
1726
|
+
abandoned = true;
|
|
1727
|
+
resolve('timeout');
|
|
1728
|
+
};
|
|
1729
|
+
pendingCancellations.add(cancel);
|
|
1730
|
+
timer = setTimeout(cancel, DIRECT_CONNECT_TIMEOUT_MS);
|
|
1480
1731
|
}),
|
|
1481
1732
|
]);
|
|
1482
|
-
if (raced === 'timeout') {
|
|
1733
|
+
if (raced === 'timeout' || disposing) {
|
|
1483
1734
|
directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
|
|
1484
1735
|
logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
|
|
1485
1736
|
deviceId,
|
|
1486
1737
|
});
|
|
1487
|
-
// Promise.race times out the caller only; a late success orphans the link.
|
|
1488
|
-
directPromise
|
|
1489
|
-
.then(late => {
|
|
1490
|
-
const latePeripheral = late ?? discoveredDevices.get(deviceId);
|
|
1491
|
-
if (
|
|
1492
|
-
latePeripheral &&
|
|
1493
|
-
latePeripheral.state === 'connected' &&
|
|
1494
|
-
!connectedDevices.has(deviceId)
|
|
1495
|
-
) {
|
|
1496
|
-
latePeripheral.removeAllListeners('disconnect');
|
|
1497
|
-
latePeripheral.disconnect(() => undefined);
|
|
1498
|
-
}
|
|
1499
|
-
})
|
|
1500
|
-
.catch(() => undefined);
|
|
1501
1738
|
return undefined;
|
|
1502
1739
|
}
|
|
1503
1740
|
// Backends emit `discover` as a side effect, so the cache may hold it.
|
|
@@ -1512,6 +1749,9 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
|
|
|
1512
1749
|
error: String(error),
|
|
1513
1750
|
});
|
|
1514
1751
|
return undefined;
|
|
1752
|
+
} finally {
|
|
1753
|
+
clearTimeout(timer);
|
|
1754
|
+
if (cancel) pendingCancellations.delete(cancel);
|
|
1515
1755
|
}
|
|
1516
1756
|
}
|
|
1517
1757
|
|
|
@@ -1582,6 +1822,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1582
1822
|
}
|
|
1583
1823
|
}
|
|
1584
1824
|
|
|
1825
|
+
assertBleActive();
|
|
1585
1826
|
// At this point, peripheral is guaranteed to be defined
|
|
1586
1827
|
if (!peripheral) {
|
|
1587
1828
|
throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
|
|
@@ -1604,6 +1845,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1604
1845
|
// Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
|
|
1605
1846
|
// and MTU listeners may still hold the webContents of a soft-restarted
|
|
1606
1847
|
// renderer, and the reuse fast path below returns before any other setup.
|
|
1848
|
+
assertBleActive();
|
|
1607
1849
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1608
1850
|
|
|
1609
1851
|
// Check if we already have characteristics for this device
|
|
@@ -1654,6 +1896,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1654
1896
|
deviceId,
|
|
1655
1897
|
webContents
|
|
1656
1898
|
);
|
|
1899
|
+
assertBleActive();
|
|
1657
1900
|
deviceCharacteristics.set(deviceId, characteristics);
|
|
1658
1901
|
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1659
1902
|
} catch (setupError) {
|
|
@@ -1665,72 +1908,43 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1665
1908
|
return;
|
|
1666
1909
|
}
|
|
1667
1910
|
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
connectionTimedOut = true;
|
|
1672
|
-
reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
|
|
1673
|
-
}, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
|
|
1674
|
-
|
|
1675
|
-
// TypeScript type assertion - peripheral is guaranteed to be defined at this point
|
|
1676
|
-
const connectedPeripheral = peripheral as Peripheral;
|
|
1677
|
-
connectedPeripheral.connect(async (error: Error | undefined) => {
|
|
1678
|
-
clearTimeout(timeout);
|
|
1911
|
+
const connectedPeripheral = peripheral;
|
|
1912
|
+
await connectPeripheralWithCancellation(connectedPeripheral, deviceId);
|
|
1913
|
+
logger?.info('[NobleBLE] Connected to device:', deviceId);
|
|
1679
1914
|
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, error.message));
|
|
1696
|
-
return;
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
logger?.info('[NobleBLE] Connected to device:', deviceId);
|
|
1700
|
-
connectedDevices.set(deviceId, connectedPeripheral);
|
|
1701
|
-
|
|
1702
|
-
// Setup connection and discover services
|
|
1703
|
-
try {
|
|
1704
|
-
const characteristics = await setupConnectionAndDiscoverServices(
|
|
1705
|
-
connectedPeripheral,
|
|
1706
|
-
deviceId,
|
|
1707
|
-
webContents
|
|
1708
|
-
);
|
|
1709
|
-
deviceCharacteristics.set(deviceId, characteristics);
|
|
1710
|
-
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1711
|
-
resolve();
|
|
1712
|
-
} catch (setupError) {
|
|
1713
|
-
logger?.error('[NobleBLE] Connection setup failed:', setupError);
|
|
1714
|
-
// Never reject from inside a raw disconnect callback: noble only fires
|
|
1715
|
-
// it on a real 'disconnect' event, so a peripheral that is already
|
|
1716
|
-
// down leaves this promise — and the renderer acquire awaiting it —
|
|
1717
|
-
// pending forever. disconnectDevice always settles (it no-ops on an
|
|
1718
|
-
// unknown peripheral and caps the confirm wait).
|
|
1719
|
-
disconnectDevice(deviceId)
|
|
1720
|
-
.catch(() => undefined)
|
|
1721
|
-
.then(() => reject(setupError));
|
|
1722
|
-
}
|
|
1723
|
-
});
|
|
1724
|
-
});
|
|
1915
|
+
// Setup connection and discover services
|
|
1916
|
+
try {
|
|
1917
|
+
const characteristics = await setupConnectionAndDiscoverServices(
|
|
1918
|
+
connectedPeripheral,
|
|
1919
|
+
deviceId,
|
|
1920
|
+
webContents
|
|
1921
|
+
);
|
|
1922
|
+
assertBleActive();
|
|
1923
|
+
deviceCharacteristics.set(deviceId, characteristics);
|
|
1924
|
+
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1925
|
+
} catch (setupError) {
|
|
1926
|
+
logger?.error('[NobleBLE] Connection setup failed:', setupError);
|
|
1927
|
+
await disconnectDevice(deviceId).catch(() => undefined);
|
|
1928
|
+
throw setupError;
|
|
1929
|
+
}
|
|
1725
1930
|
}
|
|
1726
1931
|
|
|
1727
1932
|
// Disconnect device
|
|
1728
1933
|
async function disconnectDevice(deviceId: string): Promise<void> {
|
|
1729
|
-
|
|
1934
|
+
if (nativeReleased) return;
|
|
1935
|
+
const pendingConnection = connectingDevices.get(deviceId);
|
|
1936
|
+
const peripheral = connectedDevices.get(deviceId) ?? pendingConnection?.peripheral;
|
|
1730
1937
|
if (!peripheral) {
|
|
1731
1938
|
return;
|
|
1732
1939
|
}
|
|
1733
1940
|
|
|
1941
|
+
pendingConnection?.cancel(
|
|
1942
|
+
ERRORS.TypedError(
|
|
1943
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
1944
|
+
`Device ${deviceId} connection cancelled`
|
|
1945
|
+
)
|
|
1946
|
+
);
|
|
1947
|
+
|
|
1734
1948
|
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
1735
1949
|
if (disconnectEntry) {
|
|
1736
1950
|
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
@@ -1758,6 +1972,7 @@ async function disconnectDevice(deviceId: string): Promise<void> {
|
|
|
1758
1972
|
|
|
1759
1973
|
// Unsubscribe from notifications
|
|
1760
1974
|
async function unsubscribeNotifications(deviceId: string): Promise<void> {
|
|
1975
|
+
if (nativeReleased) return;
|
|
1761
1976
|
const peripheral = connectedDevices.get(deviceId);
|
|
1762
1977
|
const characteristics = deviceCharacteristics.get(deviceId);
|
|
1763
1978
|
|
|
@@ -1785,7 +2000,7 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
|
|
|
1785
2000
|
subscribedDevices.delete(deviceId);
|
|
1786
2001
|
} finally {
|
|
1787
2002
|
// 🔒 CRITICAL: Always clear operation state (even on error)
|
|
1788
|
-
subscriptionOperations.set(deviceId, 'idle');
|
|
2003
|
+
if (!disposing) subscriptionOperations.set(deviceId, 'idle');
|
|
1789
2004
|
}
|
|
1790
2005
|
}
|
|
1791
2006
|
|
|
@@ -1874,11 +2089,13 @@ async function subscribeNotifications(
|
|
|
1874
2089
|
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
1875
2090
|
timeoutBehavior: 'resolve',
|
|
1876
2091
|
});
|
|
2092
|
+
assertBleActive();
|
|
1877
2093
|
await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
|
|
1878
|
-
timeoutMs:
|
|
2094
|
+
timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
|
|
1879
2095
|
timeoutBehavior: 'reject',
|
|
1880
2096
|
});
|
|
1881
2097
|
|
|
2098
|
+
assertBleActive();
|
|
1882
2099
|
notifyCharacteristic.on('data', (data: Buffer) => {
|
|
1883
2100
|
// Windows BLE pairing detection: receiving any data means device is paired
|
|
1884
2101
|
if (!pairedDevices.has(deviceId)) {
|
|
@@ -1893,19 +2110,27 @@ async function subscribeNotifications(
|
|
|
1893
2110
|
const subscribeStartedAt = Date.now();
|
|
1894
2111
|
try {
|
|
1895
2112
|
await rebuildAppSubscription(deviceId, notifyCharacteristic);
|
|
2113
|
+
assertBleActive();
|
|
1896
2114
|
subscribedDevices.set(deviceId, true);
|
|
1897
2115
|
logger?.info('[NobleBLE] Notification subscription active', {
|
|
1898
2116
|
deviceId,
|
|
1899
2117
|
ms: Date.now() - subscribeStartedAt,
|
|
1900
2118
|
});
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
throw createNobleBleConnectionError(
|
|
2121
|
+
error as NobleBleNativeError,
|
|
2122
|
+
'Notification subscription failed: '
|
|
2123
|
+
);
|
|
1901
2124
|
} finally {
|
|
1902
2125
|
// 🔒 CRITICAL: Always clear operation state (even on error)
|
|
1903
|
-
subscriptionOperations.set(deviceId, 'idle');
|
|
2126
|
+
if (!disposing) subscriptionOperations.set(deviceId, 'idle');
|
|
1904
2127
|
}
|
|
1905
2128
|
}
|
|
1906
2129
|
|
|
1907
2130
|
// Setup IPC handlers
|
|
1908
2131
|
export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
2132
|
+
if (disposing) return;
|
|
2133
|
+
let windowDestroyed = false;
|
|
1909
2134
|
try {
|
|
1910
2135
|
// @ts-ignore – electron-log is only available at runtime
|
|
1911
2136
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
@@ -1915,10 +2140,29 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1915
2140
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
1916
2141
|
const { ipcMain } = require('electron') as { ipcMain: IpcMain };
|
|
1917
2142
|
|
|
2143
|
+
const channels = new Set<string>();
|
|
2144
|
+
removeIpcHandlers = () => channels.forEach(channel => ipcMain.removeHandler(channel));
|
|
2145
|
+
|
|
1918
2146
|
// Electron throws on duplicate channels and setup re-runs on soft restart.
|
|
1919
2147
|
const handle: IpcMain['handle'] = (channel, listener) => {
|
|
2148
|
+
channels.add(channel);
|
|
1920
2149
|
ipcMain.removeHandler(channel);
|
|
1921
|
-
ipcMain.handle(channel,
|
|
2150
|
+
ipcMain.handle(channel, async (...args) => {
|
|
2151
|
+
try {
|
|
2152
|
+
// A replacement renderer must not race the old renderer's shared-state cleanup.
|
|
2153
|
+
if (windowCleanup) await windowCleanup;
|
|
2154
|
+
assertBleActive();
|
|
2155
|
+
if (windowDestroyed) {
|
|
2156
|
+
throw ERRORS.TypedError(
|
|
2157
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
2158
|
+
'BLE window destroyed'
|
|
2159
|
+
);
|
|
2160
|
+
}
|
|
2161
|
+
return await Promise.resolve(listener(...args));
|
|
2162
|
+
} catch (error) {
|
|
2163
|
+
return createNobleBleIpcErrorResponse(error);
|
|
2164
|
+
}
|
|
2165
|
+
});
|
|
1922
2166
|
};
|
|
1923
2167
|
|
|
1924
2168
|
safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
|
|
@@ -1926,7 +2170,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1926
2170
|
// Handle enumerate request
|
|
1927
2171
|
handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
|
|
1928
2172
|
try {
|
|
1929
|
-
const devices = await enumerateDevices();
|
|
2173
|
+
const devices = await enumerateDevices(() => windowDestroyed);
|
|
1930
2174
|
safeLog(logger, 'debug', 'Enumeration completed', {
|
|
1931
2175
|
count: devices.length,
|
|
1932
2176
|
devices: devices.map(device => ({ id: device.id, name: device.name })),
|
|
@@ -2069,16 +2313,27 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
2069
2313
|
}
|
|
2070
2314
|
});
|
|
2071
2315
|
|
|
2072
|
-
//
|
|
2316
|
+
// Window cleanup also runs during renderer soft restart; native release belongs to app quit.
|
|
2073
2317
|
webContents.on('destroyed', () => {
|
|
2318
|
+
windowDestroyed = true;
|
|
2319
|
+
if (disposing) return;
|
|
2074
2320
|
safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
|
|
2075
|
-
|
|
2321
|
+
const previousCleanup = windowCleanup;
|
|
2322
|
+
// Cancel before yielding so old timers cannot stop a replacement renderer's scan.
|
|
2323
|
+
const enumerations = Array.from(pendingEnumerations, cancel => cancel());
|
|
2324
|
+
windowCleanup = (async () => {
|
|
2325
|
+
if (previousCleanup) await previousCleanup;
|
|
2326
|
+
await Promise.allSettled(enumerations);
|
|
2327
|
+
if (disposing) return;
|
|
2076
2328
|
const deviceIds = Array.from(connectedDevices.keys());
|
|
2077
2329
|
for (const deviceId of deviceIds) {
|
|
2330
|
+
if (disposing) return;
|
|
2078
2331
|
await unsubscribeNotifications(deviceId).catch(() => undefined);
|
|
2332
|
+
if (disposing) return;
|
|
2079
2333
|
await disconnectDevice(deviceId).catch(() => undefined);
|
|
2080
2334
|
}
|
|
2081
2335
|
|
|
2336
|
+
if (disposing) return;
|
|
2082
2337
|
await stopScanning().catch(() => undefined);
|
|
2083
2338
|
// persistentStateListener is process-lifetime, NOT per-window: this
|
|
2084
2339
|
// destroy handler also fires on a renderer soft restart, and removing
|
|
@@ -2100,3 +2355,77 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
2100
2355
|
throw error;
|
|
2101
2356
|
}
|
|
2102
2357
|
}
|
|
2358
|
+
|
|
2359
|
+
/**
|
|
2360
|
+
* Terminal, bounded cleanup while the Node environment is still alive.
|
|
2361
|
+
* If Noble is shared with another transport, releaseNoble must queue the instance
|
|
2362
|
+
* and call stop() once after every transport has finished its cleanup.
|
|
2363
|
+
*/
|
|
2364
|
+
export function disposeNobleBleSupport(
|
|
2365
|
+
releaseNoble: (instance: { stop(): void }) => void = instance => instance.stop()
|
|
2366
|
+
): Promise<void> {
|
|
2367
|
+
if (disposePromise) return disposePromise;
|
|
2368
|
+
disposing = true;
|
|
2369
|
+
removeIpcHandlers?.();
|
|
2370
|
+
const instance = noble;
|
|
2371
|
+
const deviceIds = new Set([...connectedDevices.keys(), ...connectingDevices.keys()]);
|
|
2372
|
+
const pendingPeripherals = Array.from(connectingDevices.values(), pending => pending.peripheral);
|
|
2373
|
+
for (const cancel of pendingCancellations) cancel();
|
|
2374
|
+
for (const id of idleDisconnectTimers.keys()) clearIdleDisconnect(id);
|
|
2375
|
+
for (const pending of connectingDevices.values()) {
|
|
2376
|
+
pending.cancel(
|
|
2377
|
+
ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
|
|
2378
|
+
);
|
|
2379
|
+
}
|
|
2380
|
+
const connections = Array.from(nativeConnections);
|
|
2381
|
+
for (const connection of connections) {
|
|
2382
|
+
try {
|
|
2383
|
+
connection.cancel();
|
|
2384
|
+
} catch (error) {
|
|
2385
|
+
logger?.warn('[NobleBLE] Native connect cancellation failed', error);
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
2389
|
+
disposePromise = (async () => {
|
|
2390
|
+
try {
|
|
2391
|
+
await Promise.race([
|
|
2392
|
+
Promise.allSettled([
|
|
2393
|
+
windowCleanup,
|
|
2394
|
+
...connections.map(connection => connection.settled),
|
|
2395
|
+
stopScanning(),
|
|
2396
|
+
...pendingPeripherals.map(peripheral =>
|
|
2397
|
+
runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
2398
|
+
timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
|
|
2399
|
+
timeoutBehavior: 'resolve',
|
|
2400
|
+
})
|
|
2401
|
+
),
|
|
2402
|
+
...Array.from(deviceIds, async id => {
|
|
2403
|
+
await unsubscribeNotifications(id).catch(() => undefined);
|
|
2404
|
+
await disconnectDevice(id).catch(() => undefined);
|
|
2405
|
+
}),
|
|
2406
|
+
]),
|
|
2407
|
+
new Promise<void>(resolve => {
|
|
2408
|
+
timeout = setTimeout(() => {
|
|
2409
|
+
logger?.warn('[NobleBLE] Process dispose timed out; releasing native manager');
|
|
2410
|
+
resolve();
|
|
2411
|
+
}, 3500);
|
|
2412
|
+
}),
|
|
2413
|
+
]);
|
|
2414
|
+
} finally {
|
|
2415
|
+
clearTimeout(timeout);
|
|
2416
|
+
// Bounded cleanup may finish before a broken native callback; it must stay inert afterwards.
|
|
2417
|
+
nativeReleased = true;
|
|
2418
|
+
cleanupNobleListeners();
|
|
2419
|
+
if (instance && persistentStateListener) {
|
|
2420
|
+
instance.removeListener('stateChange', persistentStateListener);
|
|
2421
|
+
persistentStateListener = null;
|
|
2422
|
+
}
|
|
2423
|
+
for (const id of deviceIds) cleanupDevice(id);
|
|
2424
|
+
discoveredDevices.clear();
|
|
2425
|
+
directConnectCooldownUntil.clear();
|
|
2426
|
+
if (instance) releaseNoble(instance);
|
|
2427
|
+
logger?.info('[NobleBLE] Process dispose completed');
|
|
2428
|
+
}
|
|
2429
|
+
})();
|
|
2430
|
+
return disposePromise;
|
|
2431
|
+
}
|