@onekeyfe/hd-transport-electron 1.2.2-alpha.12 → 1.2.2-alpha.120
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-eea100c1.js → index-474dbbae.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-e4eaea92.js → noble-ble-handler-23c86603.js} +373 -99
- package/dist/noble-ble-handler.d.ts +11 -2
- 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 +775 -22
- package/src/index.ts +14 -1
- package/src/noble-ble-handler.ts +440 -119
- 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,12 +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,
|
|
18
|
-
|
|
19
|
+
isBleStaleBondHardwareError,
|
|
19
20
|
isOnekeyBluetoothDevice,
|
|
20
21
|
isPro2FamilyBleName,
|
|
21
22
|
matchesKnownBleUuid,
|
|
@@ -27,32 +28,101 @@ import { resolveBlePacketCapacity, resolveNobleAttMtu } from './ble-packet-capac
|
|
|
27
28
|
import { safeLog } from './types/noble-extended';
|
|
28
29
|
import { runBleCallbackOperation, softRefreshSubscription } from './ble-ops';
|
|
29
30
|
import {
|
|
30
|
-
|
|
31
|
+
NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
|
|
31
32
|
NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS,
|
|
32
33
|
} from './noble-ble-timeouts';
|
|
33
34
|
|
|
34
35
|
import type { IpcMain, IpcMainInvokeEvent, WebContents } from 'electron';
|
|
35
36
|
import type { Characteristic, Peripheral, Service } from '@stoprocent/noble';
|
|
36
|
-
import type { NobleBleWriteOptions } from './types/desktop-api';
|
|
37
|
+
import type { NobleBleIpcErrorResponse, NobleBleWriteOptions } from './types/desktop-api';
|
|
37
38
|
import type { CharacteristicPair, DeviceInfo, Logger, NobleModule } from './types/noble-extended';
|
|
38
39
|
|
|
39
40
|
// Noble will be dynamically imported to avoid bundling issues
|
|
40
41
|
let noble: NobleModule | null = null;
|
|
41
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
|
+
}
|
|
42
69
|
|
|
43
|
-
|
|
70
|
+
type NobleBleNativeError = Error & {
|
|
71
|
+
nativeErrorCode?: number;
|
|
72
|
+
nativeErrorDomain?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function createNobleBleConnectionError(error: NobleBleNativeError, messagePrefix = '') {
|
|
44
76
|
const errorMessage = error.message;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
);
|
|
53
89
|
}
|
|
54
90
|
|
|
55
|
-
return ERRORS.TypedError(
|
|
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
|
+
};
|
|
56
126
|
}
|
|
57
127
|
|
|
58
128
|
// Bluetooth state management
|
|
@@ -73,6 +143,102 @@ let persistentDiscoverListener: ((peripheral: Peripheral) => void) | null = null
|
|
|
73
143
|
// Device cache and connection state
|
|
74
144
|
const discoveredDevices = new Map<string, Peripheral>();
|
|
75
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
|
+
}
|
|
76
242
|
const pairedDevices = new Set<string>(); // Windows BLE device pairing status tracking
|
|
77
243
|
const deviceCharacteristics = new Map<string, CharacteristicPair>();
|
|
78
244
|
const notificationCallbacks = new Map<string, (data: string) => void>();
|
|
@@ -277,6 +443,7 @@ function updateBluetoothState(state: string): void {
|
|
|
277
443
|
|
|
278
444
|
// Initialize Noble
|
|
279
445
|
async function initializeNoble(): Promise<void> {
|
|
446
|
+
assertBleActive();
|
|
280
447
|
if (noble) return;
|
|
281
448
|
|
|
282
449
|
try {
|
|
@@ -302,12 +469,14 @@ async function initializeNoble(): Promise<void> {
|
|
|
302
469
|
}
|
|
303
470
|
|
|
304
471
|
const timeout = setTimeout(() => {
|
|
472
|
+
cleanup();
|
|
305
473
|
reject(
|
|
306
474
|
ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Bluetooth initialization timeout')
|
|
307
475
|
);
|
|
308
476
|
}, BLUETOOTH_INIT_TIMEOUT);
|
|
309
477
|
|
|
310
478
|
const cleanup = () => {
|
|
479
|
+
pendingCancellations.delete(cancel);
|
|
311
480
|
clearTimeout(timeout);
|
|
312
481
|
if (noble) {
|
|
313
482
|
noble.removeListener('stateChange', onStateChange);
|
|
@@ -332,9 +501,17 @@ async function initializeNoble(): Promise<void> {
|
|
|
332
501
|
}
|
|
333
502
|
};
|
|
334
503
|
|
|
504
|
+
const cancel = () => {
|
|
505
|
+
cleanup();
|
|
506
|
+
reject(
|
|
507
|
+
ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
|
|
508
|
+
);
|
|
509
|
+
};
|
|
510
|
+
pendingCancellations.add(cancel);
|
|
335
511
|
noble.on('stateChange', onStateChange);
|
|
336
512
|
});
|
|
337
513
|
|
|
514
|
+
assertBleActive();
|
|
338
515
|
// Set up device discovery
|
|
339
516
|
if (!persistentDiscoverListener) {
|
|
340
517
|
persistentDiscoverListener = (peripheral: Peripheral) => {
|
|
@@ -419,6 +596,7 @@ function armIdleDisconnect(
|
|
|
419
596
|
reason: 'idle' | 'busy-backstop' = 'idle'
|
|
420
597
|
): void {
|
|
421
598
|
clearIdleDisconnect(deviceId);
|
|
599
|
+
if (disposing) return;
|
|
422
600
|
idleDisconnectTimers.set(
|
|
423
601
|
deviceId,
|
|
424
602
|
setTimeout(() => {
|
|
@@ -501,6 +679,14 @@ function cleanupDevice(
|
|
|
501
679
|
|
|
502
680
|
// 1. Clean up connection state
|
|
503
681
|
if (cleanupConnection) {
|
|
682
|
+
connectingDevices
|
|
683
|
+
.get(deviceId)
|
|
684
|
+
?.cancel(
|
|
685
|
+
ERRORS.TypedError(
|
|
686
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
687
|
+
`Device ${deviceId} disconnected while connecting`
|
|
688
|
+
)
|
|
689
|
+
);
|
|
504
690
|
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
505
691
|
if (disconnectEntry) {
|
|
506
692
|
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
@@ -610,6 +796,7 @@ async function writeCharacteristicWithoutResponse(
|
|
|
610
796
|
writeCharacteristic: Characteristic,
|
|
611
797
|
buffer: Buffer
|
|
612
798
|
): Promise<void> {
|
|
799
|
+
assertBleActive();
|
|
613
800
|
return new Promise((resolve, reject) => {
|
|
614
801
|
writeCharacteristic.write(buffer, true, (error?: Error) => {
|
|
615
802
|
if (error) {
|
|
@@ -894,6 +1081,7 @@ async function performTargetedScan(
|
|
|
894
1081
|
targetDeviceId: string,
|
|
895
1082
|
timeoutMs: number = NOBLE_BLE_TARGETED_SCAN_TIMEOUT_MS
|
|
896
1083
|
): Promise<Peripheral | null> {
|
|
1084
|
+
assertBleActive();
|
|
897
1085
|
if (!noble) {
|
|
898
1086
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
899
1087
|
}
|
|
@@ -909,6 +1097,7 @@ async function performTargetedScan(
|
|
|
909
1097
|
const finish = async (peripheral: Peripheral | null, error?: Error) => {
|
|
910
1098
|
if (settled) return;
|
|
911
1099
|
settled = true;
|
|
1100
|
+
pendingCancellations.delete(cancel);
|
|
912
1101
|
if (timeoutId) clearTimeout(timeoutId);
|
|
913
1102
|
nobleInstance.removeListener('discover', onDiscover);
|
|
914
1103
|
await waitForNobleScanStop(nobleInstance);
|
|
@@ -918,6 +1107,7 @@ async function performTargetedScan(
|
|
|
918
1107
|
reject(ERRORS.TypedError(HardwareErrorCode.BleScanError, error.message));
|
|
919
1108
|
return;
|
|
920
1109
|
}
|
|
1110
|
+
assertBleActive();
|
|
921
1111
|
if (peripheral) {
|
|
922
1112
|
discoveredDevices.set(peripheral.id, peripheral);
|
|
923
1113
|
}
|
|
@@ -940,6 +1130,11 @@ async function performTargetedScan(
|
|
|
940
1130
|
finish(null).catch(reject);
|
|
941
1131
|
}, timeoutMs);
|
|
942
1132
|
|
|
1133
|
+
const cancel = () => {
|
|
1134
|
+
finish(null, new Error('Noble BLE is shutting down')).catch(reject);
|
|
1135
|
+
};
|
|
1136
|
+
pendingCancellations.add(cancel);
|
|
1137
|
+
|
|
943
1138
|
// Add local listener for this scan
|
|
944
1139
|
nobleInstance.on('discover', onDiscover);
|
|
945
1140
|
|
|
@@ -956,7 +1151,7 @@ async function performTargetedScan(
|
|
|
956
1151
|
}
|
|
957
1152
|
|
|
958
1153
|
// Enumerate devices
|
|
959
|
-
async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
1154
|
+
async function enumerateDevices(isWindowDestroyed: () => boolean): Promise<DeviceInfo[]> {
|
|
960
1155
|
if (!noble) {
|
|
961
1156
|
await initializeNoble();
|
|
962
1157
|
}
|
|
@@ -965,13 +1160,14 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
965
1160
|
throw ERRORS.TypedError(HardwareErrorCode.RuntimeError, 'Noble not available');
|
|
966
1161
|
}
|
|
967
1162
|
|
|
1163
|
+
assertBleActive();
|
|
1164
|
+
if (isWindowDestroyed()) return [];
|
|
968
1165
|
// Capture noble reference for use in closures (TypeScript narrowing)
|
|
969
1166
|
const nobleInstance = noble;
|
|
970
1167
|
|
|
971
1168
|
logger?.info('[NobleBLE] Starting device enumeration');
|
|
972
1169
|
|
|
973
|
-
//
|
|
974
|
-
discoveredDevices.clear();
|
|
1170
|
+
// Keep prior discoveries when a polling round misses an advertisement.
|
|
975
1171
|
|
|
976
1172
|
// Ensure discover listener is properly set up before scanning
|
|
977
1173
|
// This is crucial to fix the issue where devices are not found after web-usb failures
|
|
@@ -979,15 +1175,24 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
979
1175
|
|
|
980
1176
|
return new Promise((resolve, reject) => {
|
|
981
1177
|
const devices: DeviceInfo[] = [];
|
|
1178
|
+
let settled = false;
|
|
982
1179
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
1180
|
+
let cleanupPromise: Promise<void> | undefined;
|
|
983
1181
|
|
|
984
1182
|
// Cleanup function: clears timers and waits until Noble confirms scanning
|
|
985
1183
|
// has stopped. Resolving enumerate before this callback creates a race with
|
|
986
1184
|
// an immediately-following connection attempt.
|
|
987
|
-
const cleanup =
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1185
|
+
const cleanup = () => {
|
|
1186
|
+
if (!cleanupPromise) {
|
|
1187
|
+
settled = true;
|
|
1188
|
+
pendingCancellations.delete(cancel);
|
|
1189
|
+
clearTimeout(timeoutId);
|
|
1190
|
+
if (intervalId) clearInterval(intervalId);
|
|
1191
|
+
cleanupPromise = waitForNobleScanStop(nobleInstance).finally(() => {
|
|
1192
|
+
pendingEnumerations.delete(cancel);
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
return cleanupPromise;
|
|
991
1196
|
};
|
|
992
1197
|
|
|
993
1198
|
// Collect discovered devices into the devices array
|
|
@@ -1008,6 +1213,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1008
1213
|
|
|
1009
1214
|
// Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
|
|
1010
1215
|
const timeoutId = setTimeout(async () => {
|
|
1216
|
+
if (settled) return;
|
|
1011
1217
|
// Final collection before resolving — catches devices discovered near the deadline
|
|
1012
1218
|
checkDevices();
|
|
1013
1219
|
await cleanup();
|
|
@@ -1015,11 +1221,16 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1015
1221
|
resolve(devices);
|
|
1016
1222
|
}, DEVICE_SCAN_TIMEOUT);
|
|
1017
1223
|
|
|
1224
|
+
const cancel = () => cleanup().then(() => resolve([]), reject);
|
|
1225
|
+
pendingCancellations.add(cancel);
|
|
1226
|
+
pendingEnumerations.add(cancel);
|
|
1227
|
+
|
|
1018
1228
|
// Start scanning without a service UUID filter so Pro2 advertisements with
|
|
1019
1229
|
// short vendor UUIDs can be found. Repeated advertisements are required when
|
|
1020
1230
|
// the local name arrives in a later scan response; discoveredDevices handles deduplication.
|
|
1021
1231
|
logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
|
|
1022
1232
|
nobleInstance.startScanning([], true, async (error?: Error) => {
|
|
1233
|
+
if (settled) return;
|
|
1023
1234
|
if (error) {
|
|
1024
1235
|
await cleanup();
|
|
1025
1236
|
logger?.error('[NobleBLE] Failed to start scanning:', error);
|
|
@@ -1037,7 +1248,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1037
1248
|
|
|
1038
1249
|
// Stop scanning
|
|
1039
1250
|
async function stopScanning(): Promise<void> {
|
|
1040
|
-
if (!noble) return;
|
|
1251
|
+
if (!noble || nativeReleased) return;
|
|
1041
1252
|
const nobleInstance = noble;
|
|
1042
1253
|
await waitForNobleScanStop(nobleInstance);
|
|
1043
1254
|
logger?.info('[NobleBLE] Scanning stopped');
|
|
@@ -1110,13 +1321,17 @@ function getDevice(deviceId: string): DeviceInfo | null {
|
|
|
1110
1321
|
async function discoverServicesAndCharacteristics(
|
|
1111
1322
|
peripheral: Peripheral
|
|
1112
1323
|
): Promise<CharacteristicPair> {
|
|
1324
|
+
assertBleActive();
|
|
1113
1325
|
// Cleanup resources - will be set up and cleaned in try/finally
|
|
1114
1326
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
1115
1327
|
let onDisconnect: (() => void) | undefined;
|
|
1116
1328
|
|
|
1117
1329
|
const cleanup = () => {
|
|
1118
1330
|
if (timeoutId) clearTimeout(timeoutId);
|
|
1119
|
-
if (onDisconnect)
|
|
1331
|
+
if (onDisconnect) {
|
|
1332
|
+
peripheral.removeListener('disconnect', onDisconnect);
|
|
1333
|
+
pendingCancellations.delete(onDisconnect);
|
|
1334
|
+
}
|
|
1120
1335
|
};
|
|
1121
1336
|
|
|
1122
1337
|
// Racing promises for timeout and disconnect
|
|
@@ -1137,6 +1352,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
1137
1352
|
)
|
|
1138
1353
|
);
|
|
1139
1354
|
};
|
|
1355
|
+
pendingCancellations.add(onDisconnect);
|
|
1140
1356
|
peripheral.once('disconnect', onDisconnect);
|
|
1141
1357
|
});
|
|
1142
1358
|
|
|
@@ -1162,6 +1378,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
1162
1378
|
});
|
|
1163
1379
|
});
|
|
1164
1380
|
|
|
1381
|
+
assertBleActive();
|
|
1165
1382
|
if (!services || services.length === 0) {
|
|
1166
1383
|
throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
|
|
1167
1384
|
}
|
|
@@ -1268,12 +1485,8 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
|
|
|
1268
1485
|
}
|
|
1269
1486
|
|
|
1270
1487
|
// Step 3: Re-establish connection
|
|
1271
|
-
await
|
|
1272
|
-
timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
|
|
1273
|
-
timeoutBehavior: 'reject',
|
|
1274
|
-
});
|
|
1488
|
+
await connectPeripheralWithCancellation(peripheral, deviceId);
|
|
1275
1489
|
logger?.info('[NobleBLE] Force reconnect successful');
|
|
1276
|
-
connectedDevices.set(deviceId, peripheral);
|
|
1277
1490
|
|
|
1278
1491
|
// Wait for connection to stabilize
|
|
1279
1492
|
await wait(500);
|
|
@@ -1309,16 +1522,11 @@ async function freshScanAndDiscover(
|
|
|
1309
1522
|
discoveredDevices.set(deviceId, freshPeripheral);
|
|
1310
1523
|
|
|
1311
1524
|
// Connect to fresh peripheral
|
|
1312
|
-
await
|
|
1313
|
-
freshPeripheral
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
connectedDevices.set(deviceId, freshPeripheral);
|
|
1318
|
-
resolve();
|
|
1319
|
-
}
|
|
1320
|
-
});
|
|
1321
|
-
});
|
|
1525
|
+
await connectPeripheralWithCancellation(
|
|
1526
|
+
freshPeripheral,
|
|
1527
|
+
deviceId,
|
|
1528
|
+
'Fresh peripheral connection failed: '
|
|
1529
|
+
);
|
|
1322
1530
|
|
|
1323
1531
|
// Setup disconnect listener for fresh peripheral
|
|
1324
1532
|
setupDisconnectListener(freshPeripheral, deviceId, webContents);
|
|
@@ -1390,6 +1598,7 @@ async function discoverServicesAndCharacteristicsWithRetry(
|
|
|
1390
1598
|
minTimeout: 500,
|
|
1391
1599
|
maxTimeout: 3000,
|
|
1392
1600
|
onFailedAttempt: error => {
|
|
1601
|
+
assertBleActive();
|
|
1393
1602
|
// This runs after each failed attempt
|
|
1394
1603
|
logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
|
|
1395
1604
|
message: error.message,
|
|
@@ -1425,10 +1634,17 @@ async function setupConnectionAndDiscoverServices(
|
|
|
1425
1634
|
try {
|
|
1426
1635
|
await forceReconnectPeripheral(peripheral, deviceId);
|
|
1427
1636
|
} catch (resetError) {
|
|
1637
|
+
if (
|
|
1638
|
+
isBleStaleBondHardwareError(resetError) ||
|
|
1639
|
+
(resetError as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected
|
|
1640
|
+
) {
|
|
1641
|
+
throw resetError;
|
|
1642
|
+
}
|
|
1428
1643
|
// A failed reset must not abort the attempt: discovery on the existing
|
|
1429
1644
|
// link, then the fresh-scan fallback, still have a chance to recover.
|
|
1430
1645
|
logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
|
|
1431
1646
|
}
|
|
1647
|
+
assertBleActive();
|
|
1432
1648
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1433
1649
|
|
|
1434
1650
|
try {
|
|
@@ -1471,6 +1687,7 @@ const directConnectCooldownUntil = new Map<string, number>();
|
|
|
1471
1687
|
* Ported from the Trezor connector, where it is field-proven.
|
|
1472
1688
|
*/
|
|
1473
1689
|
async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
|
|
1690
|
+
assertBleActive();
|
|
1474
1691
|
const nobleInstance = noble as
|
|
1475
1692
|
| (typeof noble & {
|
|
1476
1693
|
connectAsync?: (id: string) => Promise<Peripheral | undefined>;
|
|
@@ -1481,34 +1698,42 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
|
|
|
1481
1698
|
const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
|
|
1482
1699
|
if (Date.now() < cooldownUntil) return undefined;
|
|
1483
1700
|
|
|
1701
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1702
|
+
let cancel: (() => void) | undefined;
|
|
1703
|
+
let abandoned = false;
|
|
1484
1704
|
try {
|
|
1485
1705
|
// The late-orphan guard must attach to THIS pending connect.
|
|
1486
1706
|
const directPromise = nobleInstance.connectAsync(deviceId);
|
|
1707
|
+
trackNativeConnection(
|
|
1708
|
+
directPromise.then(async late => {
|
|
1709
|
+
if ((!abandoned && !disposing) || nativeReleased) return;
|
|
1710
|
+
const peripheral = late ?? discoveredDevices.get(deviceId);
|
|
1711
|
+
if (peripheral?.state === 'connected' && !connectedDevices.has(deviceId)) {
|
|
1712
|
+
peripheral.removeAllListeners('disconnect');
|
|
1713
|
+
await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
1714
|
+
timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
|
|
1715
|
+
timeoutBehavior: 'resolve',
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
}),
|
|
1719
|
+
() => nobleInstance.cancelConnect?.(deviceId)
|
|
1720
|
+
);
|
|
1487
1721
|
const raced = await Promise.race([
|
|
1488
1722
|
directPromise,
|
|
1489
1723
|
new Promise<'timeout'>(resolve => {
|
|
1490
|
-
|
|
1724
|
+
cancel = () => {
|
|
1725
|
+
abandoned = true;
|
|
1726
|
+
resolve('timeout');
|
|
1727
|
+
};
|
|
1728
|
+
pendingCancellations.add(cancel);
|
|
1729
|
+
timer = setTimeout(cancel, DIRECT_CONNECT_TIMEOUT_MS);
|
|
1491
1730
|
}),
|
|
1492
1731
|
]);
|
|
1493
|
-
if (raced === 'timeout') {
|
|
1732
|
+
if (raced === 'timeout' || disposing) {
|
|
1494
1733
|
directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
|
|
1495
1734
|
logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
|
|
1496
1735
|
deviceId,
|
|
1497
1736
|
});
|
|
1498
|
-
// Promise.race times out the caller only; a late success orphans the link.
|
|
1499
|
-
directPromise
|
|
1500
|
-
.then(late => {
|
|
1501
|
-
const latePeripheral = late ?? discoveredDevices.get(deviceId);
|
|
1502
|
-
if (
|
|
1503
|
-
latePeripheral &&
|
|
1504
|
-
latePeripheral.state === 'connected' &&
|
|
1505
|
-
!connectedDevices.has(deviceId)
|
|
1506
|
-
) {
|
|
1507
|
-
latePeripheral.removeAllListeners('disconnect');
|
|
1508
|
-
latePeripheral.disconnect(() => undefined);
|
|
1509
|
-
}
|
|
1510
|
-
})
|
|
1511
|
-
.catch(() => undefined);
|
|
1512
1737
|
return undefined;
|
|
1513
1738
|
}
|
|
1514
1739
|
// Backends emit `discover` as a side effect, so the cache may hold it.
|
|
@@ -1523,6 +1748,9 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
|
|
|
1523
1748
|
error: String(error),
|
|
1524
1749
|
});
|
|
1525
1750
|
return undefined;
|
|
1751
|
+
} finally {
|
|
1752
|
+
clearTimeout(timer);
|
|
1753
|
+
if (cancel) pendingCancellations.delete(cancel);
|
|
1526
1754
|
}
|
|
1527
1755
|
}
|
|
1528
1756
|
|
|
@@ -1537,8 +1765,12 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1537
1765
|
totalConnected: connectedDevices.size,
|
|
1538
1766
|
});
|
|
1539
1767
|
|
|
1540
|
-
//
|
|
1541
|
-
|
|
1768
|
+
// Retained discoveries are display metadata, not proof of a reusable native link.
|
|
1769
|
+
// Prefer the live connection when an older discovery exists for the same device.
|
|
1770
|
+
let peripheral = connectedDevices.get(deviceId) ?? discoveredDevices.get(deviceId);
|
|
1771
|
+
if (peripheral?.state !== 'connected') {
|
|
1772
|
+
peripheral = undefined;
|
|
1773
|
+
}
|
|
1542
1774
|
|
|
1543
1775
|
if (!peripheral) {
|
|
1544
1776
|
// Initialize Noble if not already done
|
|
@@ -1593,6 +1825,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1593
1825
|
}
|
|
1594
1826
|
}
|
|
1595
1827
|
|
|
1828
|
+
assertBleActive();
|
|
1596
1829
|
// At this point, peripheral is guaranteed to be defined
|
|
1597
1830
|
if (!peripheral) {
|
|
1598
1831
|
throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
|
|
@@ -1615,6 +1848,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1615
1848
|
// Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
|
|
1616
1849
|
// and MTU listeners may still hold the webContents of a soft-restarted
|
|
1617
1850
|
// renderer, and the reuse fast path below returns before any other setup.
|
|
1851
|
+
assertBleActive();
|
|
1618
1852
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1619
1853
|
|
|
1620
1854
|
// Check if we already have characteristics for this device
|
|
@@ -1665,6 +1899,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1665
1899
|
deviceId,
|
|
1666
1900
|
webContents
|
|
1667
1901
|
);
|
|
1902
|
+
assertBleActive();
|
|
1668
1903
|
deviceCharacteristics.set(deviceId, characteristics);
|
|
1669
1904
|
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1670
1905
|
} catch (setupError) {
|
|
@@ -1676,72 +1911,43 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1676
1911
|
return;
|
|
1677
1912
|
}
|
|
1678
1913
|
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
connectionTimedOut = true;
|
|
1683
|
-
reject(ERRORS.TypedError(HardwareErrorCode.BleConnectedError, 'Connection timeout'));
|
|
1684
|
-
}, NOBLE_BLE_CONNECTION_TIMEOUT_MS);
|
|
1685
|
-
|
|
1686
|
-
// TypeScript type assertion - peripheral is guaranteed to be defined at this point
|
|
1687
|
-
const connectedPeripheral = peripheral as Peripheral;
|
|
1688
|
-
connectedPeripheral.connect(async (error: Error | undefined) => {
|
|
1689
|
-
clearTimeout(timeout);
|
|
1690
|
-
|
|
1691
|
-
// Noble may invoke the callback after the SDK timed out and released the request.
|
|
1692
|
-
// Ignore it to avoid initializing disposed commands or leaving an orphaned connection.
|
|
1693
|
-
if (connectionTimedOut) {
|
|
1694
|
-
if (!error) {
|
|
1695
|
-
try {
|
|
1696
|
-
connectedPeripheral.disconnect(() => undefined);
|
|
1697
|
-
} catch {
|
|
1698
|
-
// Best-effort cleanup only.
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
return;
|
|
1702
|
-
}
|
|
1914
|
+
const connectedPeripheral = peripheral;
|
|
1915
|
+
await connectPeripheralWithCancellation(connectedPeripheral, deviceId);
|
|
1916
|
+
logger?.info('[NobleBLE] Connected to device:', deviceId);
|
|
1703
1917
|
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
);
|
|
1720
|
-
deviceCharacteristics.set(deviceId, characteristics);
|
|
1721
|
-
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1722
|
-
resolve();
|
|
1723
|
-
} catch (setupError) {
|
|
1724
|
-
logger?.error('[NobleBLE] Connection setup failed:', setupError);
|
|
1725
|
-
// Never reject from inside a raw disconnect callback: noble only fires
|
|
1726
|
-
// it on a real 'disconnect' event, so a peripheral that is already
|
|
1727
|
-
// down leaves this promise — and the renderer acquire awaiting it —
|
|
1728
|
-
// pending forever. disconnectDevice always settles (it no-ops on an
|
|
1729
|
-
// unknown peripheral and caps the confirm wait).
|
|
1730
|
-
disconnectDevice(deviceId)
|
|
1731
|
-
.catch(() => undefined)
|
|
1732
|
-
.then(() => reject(setupError));
|
|
1733
|
-
}
|
|
1734
|
-
});
|
|
1735
|
-
});
|
|
1918
|
+
// Setup connection and discover services
|
|
1919
|
+
try {
|
|
1920
|
+
const characteristics = await setupConnectionAndDiscoverServices(
|
|
1921
|
+
connectedPeripheral,
|
|
1922
|
+
deviceId,
|
|
1923
|
+
webContents
|
|
1924
|
+
);
|
|
1925
|
+
assertBleActive();
|
|
1926
|
+
deviceCharacteristics.set(deviceId, characteristics);
|
|
1927
|
+
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1928
|
+
} catch (setupError) {
|
|
1929
|
+
logger?.error('[NobleBLE] Connection setup failed:', setupError);
|
|
1930
|
+
await disconnectDevice(deviceId).catch(() => undefined);
|
|
1931
|
+
throw setupError;
|
|
1932
|
+
}
|
|
1736
1933
|
}
|
|
1737
1934
|
|
|
1738
1935
|
// Disconnect device
|
|
1739
1936
|
async function disconnectDevice(deviceId: string): Promise<void> {
|
|
1740
|
-
|
|
1937
|
+
if (nativeReleased) return;
|
|
1938
|
+
const pendingConnection = connectingDevices.get(deviceId);
|
|
1939
|
+
const peripheral = connectedDevices.get(deviceId) ?? pendingConnection?.peripheral;
|
|
1741
1940
|
if (!peripheral) {
|
|
1742
1941
|
return;
|
|
1743
1942
|
}
|
|
1744
1943
|
|
|
1944
|
+
pendingConnection?.cancel(
|
|
1945
|
+
ERRORS.TypedError(
|
|
1946
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
1947
|
+
`Device ${deviceId} connection cancelled`
|
|
1948
|
+
)
|
|
1949
|
+
);
|
|
1950
|
+
|
|
1745
1951
|
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
1746
1952
|
if (disconnectEntry) {
|
|
1747
1953
|
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
@@ -1769,6 +1975,7 @@ async function disconnectDevice(deviceId: string): Promise<void> {
|
|
|
1769
1975
|
|
|
1770
1976
|
// Unsubscribe from notifications
|
|
1771
1977
|
async function unsubscribeNotifications(deviceId: string): Promise<void> {
|
|
1978
|
+
if (nativeReleased) return;
|
|
1772
1979
|
const peripheral = connectedDevices.get(deviceId);
|
|
1773
1980
|
const characteristics = deviceCharacteristics.get(deviceId);
|
|
1774
1981
|
|
|
@@ -1796,7 +2003,7 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
|
|
|
1796
2003
|
subscribedDevices.delete(deviceId);
|
|
1797
2004
|
} finally {
|
|
1798
2005
|
// 🔒 CRITICAL: Always clear operation state (even on error)
|
|
1799
|
-
subscriptionOperations.set(deviceId, 'idle');
|
|
2006
|
+
if (!disposing) subscriptionOperations.set(deviceId, 'idle');
|
|
1800
2007
|
}
|
|
1801
2008
|
}
|
|
1802
2009
|
|
|
@@ -1885,11 +2092,13 @@ async function subscribeNotifications(
|
|
|
1885
2092
|
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
1886
2093
|
timeoutBehavior: 'resolve',
|
|
1887
2094
|
});
|
|
2095
|
+
assertBleActive();
|
|
1888
2096
|
await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
|
|
1889
|
-
timeoutMs:
|
|
2097
|
+
timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
|
|
1890
2098
|
timeoutBehavior: 'reject',
|
|
1891
2099
|
});
|
|
1892
2100
|
|
|
2101
|
+
assertBleActive();
|
|
1893
2102
|
notifyCharacteristic.on('data', (data: Buffer) => {
|
|
1894
2103
|
// Windows BLE pairing detection: receiving any data means device is paired
|
|
1895
2104
|
if (!pairedDevices.has(deviceId)) {
|
|
@@ -1904,19 +2113,27 @@ async function subscribeNotifications(
|
|
|
1904
2113
|
const subscribeStartedAt = Date.now();
|
|
1905
2114
|
try {
|
|
1906
2115
|
await rebuildAppSubscription(deviceId, notifyCharacteristic);
|
|
2116
|
+
assertBleActive();
|
|
1907
2117
|
subscribedDevices.set(deviceId, true);
|
|
1908
2118
|
logger?.info('[NobleBLE] Notification subscription active', {
|
|
1909
2119
|
deviceId,
|
|
1910
2120
|
ms: Date.now() - subscribeStartedAt,
|
|
1911
2121
|
});
|
|
2122
|
+
} catch (error) {
|
|
2123
|
+
throw createNobleBleConnectionError(
|
|
2124
|
+
error as NobleBleNativeError,
|
|
2125
|
+
'Notification subscription failed: '
|
|
2126
|
+
);
|
|
1912
2127
|
} finally {
|
|
1913
2128
|
// 🔒 CRITICAL: Always clear operation state (even on error)
|
|
1914
|
-
subscriptionOperations.set(deviceId, 'idle');
|
|
2129
|
+
if (!disposing) subscriptionOperations.set(deviceId, 'idle');
|
|
1915
2130
|
}
|
|
1916
2131
|
}
|
|
1917
2132
|
|
|
1918
2133
|
// Setup IPC handlers
|
|
1919
2134
|
export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
2135
|
+
if (disposing) return;
|
|
2136
|
+
let windowDestroyed = false;
|
|
1920
2137
|
try {
|
|
1921
2138
|
// @ts-ignore – electron-log is only available at runtime
|
|
1922
2139
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
@@ -1926,10 +2143,29 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1926
2143
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
1927
2144
|
const { ipcMain } = require('electron') as { ipcMain: IpcMain };
|
|
1928
2145
|
|
|
2146
|
+
const channels = new Set<string>();
|
|
2147
|
+
removeIpcHandlers = () => channels.forEach(channel => ipcMain.removeHandler(channel));
|
|
2148
|
+
|
|
1929
2149
|
// Electron throws on duplicate channels and setup re-runs on soft restart.
|
|
1930
2150
|
const handle: IpcMain['handle'] = (channel, listener) => {
|
|
2151
|
+
channels.add(channel);
|
|
1931
2152
|
ipcMain.removeHandler(channel);
|
|
1932
|
-
ipcMain.handle(channel,
|
|
2153
|
+
ipcMain.handle(channel, async (...args) => {
|
|
2154
|
+
try {
|
|
2155
|
+
// A replacement renderer must not race the old renderer's shared-state cleanup.
|
|
2156
|
+
if (windowCleanup) await windowCleanup;
|
|
2157
|
+
assertBleActive();
|
|
2158
|
+
if (windowDestroyed) {
|
|
2159
|
+
throw ERRORS.TypedError(
|
|
2160
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
2161
|
+
'BLE window destroyed'
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
return await Promise.resolve(listener(...args));
|
|
2165
|
+
} catch (error) {
|
|
2166
|
+
return createNobleBleIpcErrorResponse(error);
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
1933
2169
|
};
|
|
1934
2170
|
|
|
1935
2171
|
safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
|
|
@@ -1937,7 +2173,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1937
2173
|
// Handle enumerate request
|
|
1938
2174
|
handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
|
|
1939
2175
|
try {
|
|
1940
|
-
const devices = await enumerateDevices();
|
|
2176
|
+
const devices = await enumerateDevices(() => windowDestroyed);
|
|
1941
2177
|
safeLog(logger, 'debug', 'Enumeration completed', {
|
|
1942
2178
|
count: devices.length,
|
|
1943
2179
|
devices: devices.map(device => ({ id: device.id, name: device.name })),
|
|
@@ -2080,16 +2316,27 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
2080
2316
|
}
|
|
2081
2317
|
});
|
|
2082
2318
|
|
|
2083
|
-
//
|
|
2319
|
+
// Window cleanup also runs during renderer soft restart; native release belongs to app quit.
|
|
2084
2320
|
webContents.on('destroyed', () => {
|
|
2321
|
+
windowDestroyed = true;
|
|
2322
|
+
if (disposing) return;
|
|
2085
2323
|
safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
|
|
2086
|
-
|
|
2324
|
+
const previousCleanup = windowCleanup;
|
|
2325
|
+
// Cancel before yielding so old timers cannot stop a replacement renderer's scan.
|
|
2326
|
+
const enumerations = Array.from(pendingEnumerations, cancel => cancel());
|
|
2327
|
+
windowCleanup = (async () => {
|
|
2328
|
+
if (previousCleanup) await previousCleanup;
|
|
2329
|
+
await Promise.allSettled(enumerations);
|
|
2330
|
+
if (disposing) return;
|
|
2087
2331
|
const deviceIds = Array.from(connectedDevices.keys());
|
|
2088
2332
|
for (const deviceId of deviceIds) {
|
|
2333
|
+
if (disposing) return;
|
|
2089
2334
|
await unsubscribeNotifications(deviceId).catch(() => undefined);
|
|
2335
|
+
if (disposing) return;
|
|
2090
2336
|
await disconnectDevice(deviceId).catch(() => undefined);
|
|
2091
2337
|
}
|
|
2092
2338
|
|
|
2339
|
+
if (disposing) return;
|
|
2093
2340
|
await stopScanning().catch(() => undefined);
|
|
2094
2341
|
// persistentStateListener is process-lifetime, NOT per-window: this
|
|
2095
2342
|
// destroy handler also fires on a renderer soft restart, and removing
|
|
@@ -2111,3 +2358,77 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
2111
2358
|
throw error;
|
|
2112
2359
|
}
|
|
2113
2360
|
}
|
|
2361
|
+
|
|
2362
|
+
/**
|
|
2363
|
+
* Terminal, bounded cleanup while the Node environment is still alive.
|
|
2364
|
+
* If Noble is shared with another transport, releaseNoble must queue the instance
|
|
2365
|
+
* and call stop() once after every transport has finished its cleanup.
|
|
2366
|
+
*/
|
|
2367
|
+
export function disposeNobleBleSupport(
|
|
2368
|
+
releaseNoble: (instance: { stop(): void }) => void = instance => instance.stop()
|
|
2369
|
+
): Promise<void> {
|
|
2370
|
+
if (disposePromise) return disposePromise;
|
|
2371
|
+
disposing = true;
|
|
2372
|
+
removeIpcHandlers?.();
|
|
2373
|
+
const instance = noble;
|
|
2374
|
+
const deviceIds = new Set([...connectedDevices.keys(), ...connectingDevices.keys()]);
|
|
2375
|
+
const pendingPeripherals = Array.from(connectingDevices.values(), pending => pending.peripheral);
|
|
2376
|
+
for (const cancel of pendingCancellations) cancel();
|
|
2377
|
+
for (const id of idleDisconnectTimers.keys()) clearIdleDisconnect(id);
|
|
2378
|
+
for (const pending of connectingDevices.values()) {
|
|
2379
|
+
pending.cancel(
|
|
2380
|
+
ERRORS.TypedError(HardwareErrorCode.BleDeviceDisconnected, 'Noble BLE is shutting down')
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
const connections = Array.from(nativeConnections);
|
|
2384
|
+
for (const connection of connections) {
|
|
2385
|
+
try {
|
|
2386
|
+
connection.cancel();
|
|
2387
|
+
} catch (error) {
|
|
2388
|
+
logger?.warn('[NobleBLE] Native connect cancellation failed', error);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
2392
|
+
disposePromise = (async () => {
|
|
2393
|
+
try {
|
|
2394
|
+
await Promise.race([
|
|
2395
|
+
Promise.allSettled([
|
|
2396
|
+
windowCleanup,
|
|
2397
|
+
...connections.map(connection => connection.settled),
|
|
2398
|
+
stopScanning(),
|
|
2399
|
+
...pendingPeripherals.map(peripheral =>
|
|
2400
|
+
runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
|
|
2401
|
+
timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
|
|
2402
|
+
timeoutBehavior: 'resolve',
|
|
2403
|
+
})
|
|
2404
|
+
),
|
|
2405
|
+
...Array.from(deviceIds, async id => {
|
|
2406
|
+
await unsubscribeNotifications(id).catch(() => undefined);
|
|
2407
|
+
await disconnectDevice(id).catch(() => undefined);
|
|
2408
|
+
}),
|
|
2409
|
+
]),
|
|
2410
|
+
new Promise<void>(resolve => {
|
|
2411
|
+
timeout = setTimeout(() => {
|
|
2412
|
+
logger?.warn('[NobleBLE] Process dispose timed out; releasing native manager');
|
|
2413
|
+
resolve();
|
|
2414
|
+
}, 3500);
|
|
2415
|
+
}),
|
|
2416
|
+
]);
|
|
2417
|
+
} finally {
|
|
2418
|
+
clearTimeout(timeout);
|
|
2419
|
+
// Bounded cleanup may finish before a broken native callback; it must stay inert afterwards.
|
|
2420
|
+
nativeReleased = true;
|
|
2421
|
+
cleanupNobleListeners();
|
|
2422
|
+
if (instance && persistentStateListener) {
|
|
2423
|
+
instance.removeListener('stateChange', persistentStateListener);
|
|
2424
|
+
persistentStateListener = null;
|
|
2425
|
+
}
|
|
2426
|
+
for (const id of deviceIds) cleanupDevice(id);
|
|
2427
|
+
discoveredDevices.clear();
|
|
2428
|
+
directConnectCooldownUntil.clear();
|
|
2429
|
+
if (instance) releaseNoble(instance);
|
|
2430
|
+
logger?.info('[NobleBLE] Process dispose completed');
|
|
2431
|
+
}
|
|
2432
|
+
})();
|
|
2433
|
+
return disposePromise;
|
|
2434
|
+
}
|