@onekeyfe/hd-transport-electron 1.2.2-alpha.9 → 1.2.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-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-95c5cf0d.js → noble-ble-handler-23c86603.js} +378 -90
- 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 +789 -18
- package/src/index.ts +14 -1
- package/src/noble-ble-handler.ts +445 -113
- 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,13 +1160,14 @@ 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
|
|
|
955
1168
|
logger?.info('[NobleBLE] Starting device enumeration');
|
|
956
1169
|
|
|
957
|
-
//
|
|
958
|
-
discoveredDevices.clear();
|
|
1170
|
+
// Keep prior discoveries when a polling round misses an advertisement.
|
|
959
1171
|
|
|
960
1172
|
// Ensure discover listener is properly set up before scanning
|
|
961
1173
|
// This is crucial to fix the issue where devices are not found after web-usb failures
|
|
@@ -963,15 +1175,24 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
963
1175
|
|
|
964
1176
|
return new Promise((resolve, reject) => {
|
|
965
1177
|
const devices: DeviceInfo[] = [];
|
|
1178
|
+
let settled = false;
|
|
966
1179
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
1180
|
+
let cleanupPromise: Promise<void> | undefined;
|
|
967
1181
|
|
|
968
1182
|
// Cleanup function: clears timers and waits until Noble confirms scanning
|
|
969
1183
|
// has stopped. Resolving enumerate before this callback creates a race with
|
|
970
1184
|
// an immediately-following connection attempt.
|
|
971
|
-
const cleanup =
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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;
|
|
975
1196
|
};
|
|
976
1197
|
|
|
977
1198
|
// Collect discovered devices into the devices array
|
|
@@ -992,6 +1213,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
992
1213
|
|
|
993
1214
|
// Set timeout for scanning — use longer timeout to catch slow-advertising devices like Pro2
|
|
994
1215
|
const timeoutId = setTimeout(async () => {
|
|
1216
|
+
if (settled) return;
|
|
995
1217
|
// Final collection before resolving — catches devices discovered near the deadline
|
|
996
1218
|
checkDevices();
|
|
997
1219
|
await cleanup();
|
|
@@ -999,11 +1221,16 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
999
1221
|
resolve(devices);
|
|
1000
1222
|
}, DEVICE_SCAN_TIMEOUT);
|
|
1001
1223
|
|
|
1224
|
+
const cancel = () => cleanup().then(() => resolve([]), reject);
|
|
1225
|
+
pendingCancellations.add(cancel);
|
|
1226
|
+
pendingEnumerations.add(cancel);
|
|
1227
|
+
|
|
1002
1228
|
// Start scanning without a service UUID filter so Pro2 advertisements with
|
|
1003
1229
|
// short vendor UUIDs can be found. Repeated advertisements are required when
|
|
1004
1230
|
// the local name arrives in a later scan response; discoveredDevices handles deduplication.
|
|
1005
1231
|
logger?.info('[NobleBLE] Scanning for OneKey BLE devices');
|
|
1006
1232
|
nobleInstance.startScanning([], true, async (error?: Error) => {
|
|
1233
|
+
if (settled) return;
|
|
1007
1234
|
if (error) {
|
|
1008
1235
|
await cleanup();
|
|
1009
1236
|
logger?.error('[NobleBLE] Failed to start scanning:', error);
|
|
@@ -1021,7 +1248,7 @@ async function enumerateDevices(): Promise<DeviceInfo[]> {
|
|
|
1021
1248
|
|
|
1022
1249
|
// Stop scanning
|
|
1023
1250
|
async function stopScanning(): Promise<void> {
|
|
1024
|
-
if (!noble) return;
|
|
1251
|
+
if (!noble || nativeReleased) return;
|
|
1025
1252
|
const nobleInstance = noble;
|
|
1026
1253
|
await waitForNobleScanStop(nobleInstance);
|
|
1027
1254
|
logger?.info('[NobleBLE] Scanning stopped');
|
|
@@ -1094,13 +1321,17 @@ function getDevice(deviceId: string): DeviceInfo | null {
|
|
|
1094
1321
|
async function discoverServicesAndCharacteristics(
|
|
1095
1322
|
peripheral: Peripheral
|
|
1096
1323
|
): Promise<CharacteristicPair> {
|
|
1324
|
+
assertBleActive();
|
|
1097
1325
|
// Cleanup resources - will be set up and cleaned in try/finally
|
|
1098
1326
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
1099
1327
|
let onDisconnect: (() => void) | undefined;
|
|
1100
1328
|
|
|
1101
1329
|
const cleanup = () => {
|
|
1102
1330
|
if (timeoutId) clearTimeout(timeoutId);
|
|
1103
|
-
if (onDisconnect)
|
|
1331
|
+
if (onDisconnect) {
|
|
1332
|
+
peripheral.removeListener('disconnect', onDisconnect);
|
|
1333
|
+
pendingCancellations.delete(onDisconnect);
|
|
1334
|
+
}
|
|
1104
1335
|
};
|
|
1105
1336
|
|
|
1106
1337
|
// Racing promises for timeout and disconnect
|
|
@@ -1121,6 +1352,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
1121
1352
|
)
|
|
1122
1353
|
);
|
|
1123
1354
|
};
|
|
1355
|
+
pendingCancellations.add(onDisconnect);
|
|
1124
1356
|
peripheral.once('disconnect', onDisconnect);
|
|
1125
1357
|
});
|
|
1126
1358
|
|
|
@@ -1146,6 +1378,7 @@ async function discoverServicesAndCharacteristics(
|
|
|
1146
1378
|
});
|
|
1147
1379
|
});
|
|
1148
1380
|
|
|
1381
|
+
assertBleActive();
|
|
1149
1382
|
if (!services || services.length === 0) {
|
|
1150
1383
|
throw ERRORS.TypedError(HardwareErrorCode.BleServiceNotFound, 'No OneKey services found');
|
|
1151
1384
|
}
|
|
@@ -1252,12 +1485,8 @@ async function forceReconnectPeripheral(peripheral: Peripheral, deviceId: string
|
|
|
1252
1485
|
}
|
|
1253
1486
|
|
|
1254
1487
|
// Step 3: Re-establish connection
|
|
1255
|
-
await
|
|
1256
|
-
timeoutMs: NOBLE_BLE_CONNECTION_TIMEOUT_MS,
|
|
1257
|
-
timeoutBehavior: 'reject',
|
|
1258
|
-
});
|
|
1488
|
+
await connectPeripheralWithCancellation(peripheral, deviceId);
|
|
1259
1489
|
logger?.info('[NobleBLE] Force reconnect successful');
|
|
1260
|
-
connectedDevices.set(deviceId, peripheral);
|
|
1261
1490
|
|
|
1262
1491
|
// Wait for connection to stabilize
|
|
1263
1492
|
await wait(500);
|
|
@@ -1293,21 +1522,11 @@ async function freshScanAndDiscover(
|
|
|
1293
1522
|
discoveredDevices.set(deviceId, freshPeripheral);
|
|
1294
1523
|
|
|
1295
1524
|
// 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
|
-
});
|
|
1525
|
+
await connectPeripheralWithCancellation(
|
|
1526
|
+
freshPeripheral,
|
|
1527
|
+
deviceId,
|
|
1528
|
+
'Fresh peripheral connection failed: '
|
|
1529
|
+
);
|
|
1311
1530
|
|
|
1312
1531
|
// Setup disconnect listener for fresh peripheral
|
|
1313
1532
|
setupDisconnectListener(freshPeripheral, deviceId, webContents);
|
|
@@ -1379,6 +1598,7 @@ async function discoverServicesAndCharacteristicsWithRetry(
|
|
|
1379
1598
|
minTimeout: 500,
|
|
1380
1599
|
maxTimeout: 3000,
|
|
1381
1600
|
onFailedAttempt: error => {
|
|
1601
|
+
assertBleActive();
|
|
1382
1602
|
// This runs after each failed attempt
|
|
1383
1603
|
logger?.error(`[NobleBLE] Service discovery attempt ${error.attemptNumber} failed:`, {
|
|
1384
1604
|
message: error.message,
|
|
@@ -1414,10 +1634,17 @@ async function setupConnectionAndDiscoverServices(
|
|
|
1414
1634
|
try {
|
|
1415
1635
|
await forceReconnectPeripheral(peripheral, deviceId);
|
|
1416
1636
|
} catch (resetError) {
|
|
1637
|
+
if (
|
|
1638
|
+
isBleStaleBondHardwareError(resetError) ||
|
|
1639
|
+
(resetError as { errorCode?: unknown })?.errorCode === HardwareErrorCode.BleDeviceDisconnected
|
|
1640
|
+
) {
|
|
1641
|
+
throw resetError;
|
|
1642
|
+
}
|
|
1417
1643
|
// A failed reset must not abort the attempt: discovery on the existing
|
|
1418
1644
|
// link, then the fresh-scan fallback, still have a chance to recover.
|
|
1419
1645
|
logger?.error('[NobleBLE] Connection reset before discovery failed, continuing', resetError);
|
|
1420
1646
|
}
|
|
1647
|
+
assertBleActive();
|
|
1421
1648
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1422
1649
|
|
|
1423
1650
|
try {
|
|
@@ -1460,6 +1687,7 @@ const directConnectCooldownUntil = new Map<string, number>();
|
|
|
1460
1687
|
* Ported from the Trezor connector, where it is field-proven.
|
|
1461
1688
|
*/
|
|
1462
1689
|
async function tryDirectConnectById(deviceId: string): Promise<Peripheral | undefined> {
|
|
1690
|
+
assertBleActive();
|
|
1463
1691
|
const nobleInstance = noble as
|
|
1464
1692
|
| (typeof noble & {
|
|
1465
1693
|
connectAsync?: (id: string) => Promise<Peripheral | undefined>;
|
|
@@ -1470,34 +1698,42 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
|
|
|
1470
1698
|
const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
|
|
1471
1699
|
if (Date.now() < cooldownUntil) return undefined;
|
|
1472
1700
|
|
|
1701
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1702
|
+
let cancel: (() => void) | undefined;
|
|
1703
|
+
let abandoned = false;
|
|
1473
1704
|
try {
|
|
1474
1705
|
// The late-orphan guard must attach to THIS pending connect.
|
|
1475
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
|
+
);
|
|
1476
1721
|
const raced = await Promise.race([
|
|
1477
1722
|
directPromise,
|
|
1478
1723
|
new Promise<'timeout'>(resolve => {
|
|
1479
|
-
|
|
1724
|
+
cancel = () => {
|
|
1725
|
+
abandoned = true;
|
|
1726
|
+
resolve('timeout');
|
|
1727
|
+
};
|
|
1728
|
+
pendingCancellations.add(cancel);
|
|
1729
|
+
timer = setTimeout(cancel, DIRECT_CONNECT_TIMEOUT_MS);
|
|
1480
1730
|
}),
|
|
1481
1731
|
]);
|
|
1482
|
-
if (raced === 'timeout') {
|
|
1732
|
+
if (raced === 'timeout' || disposing) {
|
|
1483
1733
|
directConnectCooldownUntil.set(deviceId, Date.now() + DIRECT_CONNECT_COOLDOWN_MS);
|
|
1484
1734
|
logger?.info('[NobleBLE] Direct connect-by-id timed out, falling back to scan', {
|
|
1485
1735
|
deviceId,
|
|
1486
1736
|
});
|
|
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
1737
|
return undefined;
|
|
1502
1738
|
}
|
|
1503
1739
|
// Backends emit `discover` as a side effect, so the cache may hold it.
|
|
@@ -1512,6 +1748,9 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
|
|
|
1512
1748
|
error: String(error),
|
|
1513
1749
|
});
|
|
1514
1750
|
return undefined;
|
|
1751
|
+
} finally {
|
|
1752
|
+
clearTimeout(timer);
|
|
1753
|
+
if (cancel) pendingCancellations.delete(cancel);
|
|
1515
1754
|
}
|
|
1516
1755
|
}
|
|
1517
1756
|
|
|
@@ -1526,8 +1765,12 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1526
1765
|
totalConnected: connectedDevices.size,
|
|
1527
1766
|
});
|
|
1528
1767
|
|
|
1529
|
-
//
|
|
1530
|
-
|
|
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
|
+
}
|
|
1531
1774
|
|
|
1532
1775
|
if (!peripheral) {
|
|
1533
1776
|
// Initialize Noble if not already done
|
|
@@ -1582,6 +1825,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1582
1825
|
}
|
|
1583
1826
|
}
|
|
1584
1827
|
|
|
1828
|
+
assertBleActive();
|
|
1585
1829
|
// At this point, peripheral is guaranteed to be defined
|
|
1586
1830
|
if (!peripheral) {
|
|
1587
1831
|
throw ERRORS.TypedError(HardwareErrorCode.DeviceNotFound, `Device ${deviceId} not found`);
|
|
@@ -1604,6 +1848,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1604
1848
|
// Re-bind unconditionally (idempotent): on a kept-alive link the disconnect
|
|
1605
1849
|
// and MTU listeners may still hold the webContents of a soft-restarted
|
|
1606
1850
|
// renderer, and the reuse fast path below returns before any other setup.
|
|
1851
|
+
assertBleActive();
|
|
1607
1852
|
setupDisconnectListener(peripheral, deviceId, webContents);
|
|
1608
1853
|
|
|
1609
1854
|
// Check if we already have characteristics for this device
|
|
@@ -1654,6 +1899,7 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1654
1899
|
deviceId,
|
|
1655
1900
|
webContents
|
|
1656
1901
|
);
|
|
1902
|
+
assertBleActive();
|
|
1657
1903
|
deviceCharacteristics.set(deviceId, characteristics);
|
|
1658
1904
|
logger?.info('[NobleBLE] Device ready for communication:', deviceId);
|
|
1659
1905
|
} catch (setupError) {
|
|
@@ -1665,72 +1911,43 @@ async function connectDevice(deviceId: string, webContents: WebContents): Promis
|
|
|
1665
1911
|
return;
|
|
1666
1912
|
}
|
|
1667
1913
|
|
|
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);
|
|
1914
|
+
const connectedPeripheral = peripheral;
|
|
1915
|
+
await connectPeripheralWithCancellation(connectedPeripheral, deviceId);
|
|
1916
|
+
logger?.info('[NobleBLE] Connected to device:', deviceId);
|
|
1679
1917
|
|
|
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
|
-
});
|
|
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
|
+
}
|
|
1725
1933
|
}
|
|
1726
1934
|
|
|
1727
1935
|
// Disconnect device
|
|
1728
1936
|
async function disconnectDevice(deviceId: string): Promise<void> {
|
|
1729
|
-
|
|
1937
|
+
if (nativeReleased) return;
|
|
1938
|
+
const pendingConnection = connectingDevices.get(deviceId);
|
|
1939
|
+
const peripheral = connectedDevices.get(deviceId) ?? pendingConnection?.peripheral;
|
|
1730
1940
|
if (!peripheral) {
|
|
1731
1941
|
return;
|
|
1732
1942
|
}
|
|
1733
1943
|
|
|
1944
|
+
pendingConnection?.cancel(
|
|
1945
|
+
ERRORS.TypedError(
|
|
1946
|
+
HardwareErrorCode.BleDeviceDisconnected,
|
|
1947
|
+
`Device ${deviceId} connection cancelled`
|
|
1948
|
+
)
|
|
1949
|
+
);
|
|
1950
|
+
|
|
1734
1951
|
const disconnectEntry = deviceDisconnectListeners.get(deviceId);
|
|
1735
1952
|
if (disconnectEntry) {
|
|
1736
1953
|
disconnectEntry.peripheral.removeListener('disconnect', disconnectEntry.listener);
|
|
@@ -1758,6 +1975,7 @@ async function disconnectDevice(deviceId: string): Promise<void> {
|
|
|
1758
1975
|
|
|
1759
1976
|
// Unsubscribe from notifications
|
|
1760
1977
|
async function unsubscribeNotifications(deviceId: string): Promise<void> {
|
|
1978
|
+
if (nativeReleased) return;
|
|
1761
1979
|
const peripheral = connectedDevices.get(deviceId);
|
|
1762
1980
|
const characteristics = deviceCharacteristics.get(deviceId);
|
|
1763
1981
|
|
|
@@ -1785,7 +2003,7 @@ async function unsubscribeNotifications(deviceId: string): Promise<void> {
|
|
|
1785
2003
|
subscribedDevices.delete(deviceId);
|
|
1786
2004
|
} finally {
|
|
1787
2005
|
// 🔒 CRITICAL: Always clear operation state (even on error)
|
|
1788
|
-
subscriptionOperations.set(deviceId, 'idle');
|
|
2006
|
+
if (!disposing) subscriptionOperations.set(deviceId, 'idle');
|
|
1789
2007
|
}
|
|
1790
2008
|
}
|
|
1791
2009
|
|
|
@@ -1874,11 +2092,13 @@ async function subscribeNotifications(
|
|
|
1874
2092
|
timeoutMs: BLE_CLEANUP_TIMEOUT,
|
|
1875
2093
|
timeoutBehavior: 'resolve',
|
|
1876
2094
|
});
|
|
2095
|
+
assertBleActive();
|
|
1877
2096
|
await runBleCallbackOperation(callback => notifyCharacteristic.subscribe(callback), {
|
|
1878
|
-
timeoutMs:
|
|
2097
|
+
timeoutMs: NOBLE_BLE_SUBSCRIBE_TIMEOUT_MS,
|
|
1879
2098
|
timeoutBehavior: 'reject',
|
|
1880
2099
|
});
|
|
1881
2100
|
|
|
2101
|
+
assertBleActive();
|
|
1882
2102
|
notifyCharacteristic.on('data', (data: Buffer) => {
|
|
1883
2103
|
// Windows BLE pairing detection: receiving any data means device is paired
|
|
1884
2104
|
if (!pairedDevices.has(deviceId)) {
|
|
@@ -1893,19 +2113,27 @@ async function subscribeNotifications(
|
|
|
1893
2113
|
const subscribeStartedAt = Date.now();
|
|
1894
2114
|
try {
|
|
1895
2115
|
await rebuildAppSubscription(deviceId, notifyCharacteristic);
|
|
2116
|
+
assertBleActive();
|
|
1896
2117
|
subscribedDevices.set(deviceId, true);
|
|
1897
2118
|
logger?.info('[NobleBLE] Notification subscription active', {
|
|
1898
2119
|
deviceId,
|
|
1899
2120
|
ms: Date.now() - subscribeStartedAt,
|
|
1900
2121
|
});
|
|
2122
|
+
} catch (error) {
|
|
2123
|
+
throw createNobleBleConnectionError(
|
|
2124
|
+
error as NobleBleNativeError,
|
|
2125
|
+
'Notification subscription failed: '
|
|
2126
|
+
);
|
|
1901
2127
|
} finally {
|
|
1902
2128
|
// 🔒 CRITICAL: Always clear operation state (even on error)
|
|
1903
|
-
subscriptionOperations.set(deviceId, 'idle');
|
|
2129
|
+
if (!disposing) subscriptionOperations.set(deviceId, 'idle');
|
|
1904
2130
|
}
|
|
1905
2131
|
}
|
|
1906
2132
|
|
|
1907
2133
|
// Setup IPC handlers
|
|
1908
2134
|
export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
2135
|
+
if (disposing) return;
|
|
2136
|
+
let windowDestroyed = false;
|
|
1909
2137
|
try {
|
|
1910
2138
|
// @ts-ignore – electron-log is only available at runtime
|
|
1911
2139
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
@@ -1915,10 +2143,29 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1915
2143
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
1916
2144
|
const { ipcMain } = require('electron') as { ipcMain: IpcMain };
|
|
1917
2145
|
|
|
2146
|
+
const channels = new Set<string>();
|
|
2147
|
+
removeIpcHandlers = () => channels.forEach(channel => ipcMain.removeHandler(channel));
|
|
2148
|
+
|
|
1918
2149
|
// Electron throws on duplicate channels and setup re-runs on soft restart.
|
|
1919
2150
|
const handle: IpcMain['handle'] = (channel, listener) => {
|
|
2151
|
+
channels.add(channel);
|
|
1920
2152
|
ipcMain.removeHandler(channel);
|
|
1921
|
-
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
|
+
});
|
|
1922
2169
|
};
|
|
1923
2170
|
|
|
1924
2171
|
safeLog(logger, 'info', 'Setting up Noble BLE IPC handlers');
|
|
@@ -1926,7 +2173,7 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
1926
2173
|
// Handle enumerate request
|
|
1927
2174
|
handle(EOneKeyBleMessageKeys.NOBLE_BLE_ENUMERATE, async () => {
|
|
1928
2175
|
try {
|
|
1929
|
-
const devices = await enumerateDevices();
|
|
2176
|
+
const devices = await enumerateDevices(() => windowDestroyed);
|
|
1930
2177
|
safeLog(logger, 'debug', 'Enumeration completed', {
|
|
1931
2178
|
count: devices.length,
|
|
1932
2179
|
devices: devices.map(device => ({ id: device.id, name: device.name })),
|
|
@@ -2069,16 +2316,27 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
2069
2316
|
}
|
|
2070
2317
|
});
|
|
2071
2318
|
|
|
2072
|
-
//
|
|
2319
|
+
// Window cleanup also runs during renderer soft restart; native release belongs to app quit.
|
|
2073
2320
|
webContents.on('destroyed', () => {
|
|
2321
|
+
windowDestroyed = true;
|
|
2322
|
+
if (disposing) return;
|
|
2074
2323
|
safeLog(logger, 'info', 'Cleaning up Noble BLE handlers');
|
|
2075
|
-
|
|
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;
|
|
2076
2331
|
const deviceIds = Array.from(connectedDevices.keys());
|
|
2077
2332
|
for (const deviceId of deviceIds) {
|
|
2333
|
+
if (disposing) return;
|
|
2078
2334
|
await unsubscribeNotifications(deviceId).catch(() => undefined);
|
|
2335
|
+
if (disposing) return;
|
|
2079
2336
|
await disconnectDevice(deviceId).catch(() => undefined);
|
|
2080
2337
|
}
|
|
2081
2338
|
|
|
2339
|
+
if (disposing) return;
|
|
2082
2340
|
await stopScanning().catch(() => undefined);
|
|
2083
2341
|
// persistentStateListener is process-lifetime, NOT per-window: this
|
|
2084
2342
|
// destroy handler also fires on a renderer soft restart, and removing
|
|
@@ -2100,3 +2358,77 @@ export function setupNobleBleHandlers(webContents: WebContents): void {
|
|
|
2100
2358
|
throw error;
|
|
2101
2359
|
}
|
|
2102
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
|
+
}
|