@onekeyfe/hd-transport-electron 1.2.8-alpha.130 → 1.2.8-alpha.132

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.
@@ -55,13 +55,13 @@ function invokeNobleBleIpc(request) {
55
55
 
56
56
  function initNobleBleSupport(webContents) {
57
57
  return __awaiter(this, void 0, void 0, function* () {
58
- const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-180255d0.js'); });
58
+ const { setupNobleBleHandlers } = yield Promise.resolve().then(function () { return require('./noble-ble-handler-a420607d.js'); });
59
59
  setupNobleBleHandlers(webContents);
60
60
  });
61
61
  }
62
62
  function disposeNobleBleSupport(releaseNoble) {
63
63
  return __awaiter(this, void 0, void 0, function* () {
64
- const handler = yield Promise.resolve().then(function () { return require('./noble-ble-handler-180255d0.js'); });
64
+ const handler = yield Promise.resolve().then(function () { return require('./noble-ble-handler-a420607d.js'); });
65
65
  yield handler.disposeNobleBleSupport(releaseNoble);
66
66
  });
67
67
  }
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
- var index = require('./index-f41d5fc1.js');
5
+ var index = require('./index-5b7f7420.js');
6
6
 
7
7
 
8
8
 
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var index = require('./index-f41d5fc1.js');
3
+ var index = require('./index-5b7f7420.js');
4
4
  var hdShared = require('@onekeyfe/hd-shared');
5
5
  var pRetry = require('p-retry');
6
6
 
@@ -1224,6 +1224,8 @@ function setupConnectionAndDiscoverServices(peripheral, deviceId, webContents) {
1224
1224
  const DIRECT_CONNECT_TIMEOUT_MS = 2000;
1225
1225
  const DIRECT_CONNECT_COOLDOWN_MS = 15000;
1226
1226
  const directConnectCooldownUntil = new Map();
1227
+ const pendingDirectNativeConnections = new Set();
1228
+ const deviceConnectionPromises = new Map();
1227
1229
  function tryDirectConnectById(deviceId) {
1228
1230
  var _a;
1229
1231
  return index.__awaiter(this, void 0, void 0, function* () {
@@ -1231,6 +1233,8 @@ function tryDirectConnectById(deviceId) {
1231
1233
  const nobleInstance = noble;
1232
1234
  if (!(nobleInstance === null || nobleInstance === void 0 ? void 0 : nobleInstance.connectAsync))
1233
1235
  return undefined;
1236
+ if (pendingDirectNativeConnections.has(deviceId))
1237
+ return undefined;
1234
1238
  const cooldownUntil = (_a = directConnectCooldownUntil.get(deviceId)) !== null && _a !== void 0 ? _a : 0;
1235
1239
  if (Date.now() < cooldownUntil)
1236
1240
  return undefined;
@@ -1239,7 +1243,9 @@ function tryDirectConnectById(deviceId) {
1239
1243
  let abandoned = false;
1240
1244
  try {
1241
1245
  const directPromise = nobleInstance.connectAsync(deviceId);
1242
- trackNativeConnection(directPromise.then((late) => index.__awaiter(this, void 0, void 0, function* () {
1246
+ pendingDirectNativeConnections.add(deviceId);
1247
+ trackNativeConnection(directPromise
1248
+ .then((late) => index.__awaiter(this, void 0, void 0, function* () {
1243
1249
  if ((!abandoned && !disposing) || nativeReleased)
1244
1250
  return;
1245
1251
  const peripheral = late !== null && late !== void 0 ? late : discoveredDevices.get(deviceId);
@@ -1250,7 +1256,8 @@ function tryDirectConnectById(deviceId) {
1250
1256
  timeoutBehavior: 'resolve',
1251
1257
  });
1252
1258
  }
1253
- })), () => { var _a; return (_a = nobleInstance.cancelConnect) === null || _a === void 0 ? void 0 : _a.call(nobleInstance, deviceId); });
1259
+ }))
1260
+ .finally(() => pendingDirectNativeConnections.delete(deviceId)), () => { var _a; return (_a = nobleInstance.cancelConnect) === null || _a === void 0 ? void 0 : _a.call(nobleInstance, deviceId); });
1254
1261
  const raced = yield Promise.race([
1255
1262
  directPromise,
1256
1263
  new Promise(resolve => {
@@ -1291,6 +1298,16 @@ function tryDirectConnectById(deviceId) {
1291
1298
  });
1292
1299
  }
1293
1300
  function connectDevice(deviceId, webContents) {
1301
+ const pending = deviceConnectionPromises.get(deviceId);
1302
+ if (pending)
1303
+ return pending;
1304
+ const connecting = connectDeviceOnce(deviceId, webContents).finally(() => {
1305
+ deviceConnectionPromises.delete(deviceId);
1306
+ });
1307
+ deviceConnectionPromises.set(deviceId, connecting);
1308
+ return connecting;
1309
+ }
1310
+ function connectDeviceOnce(deviceId, webContents) {
1294
1311
  var _a, _b, _c;
1295
1312
  return index.__awaiter(this, void 0, void 0, function* () {
1296
1313
  yield initializeNoble();
@@ -1 +1 @@
1
- {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAA+B,WAAW,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,KAAK,EAAE,wBAAwB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAiC1F,KAAK,mBAAmB,GAAG,KAAK,GAAG;IACjC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,mBAAmB,EAAE,aAAa,SAAK,+CAG3F;AAED,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,OAAO,GAAG,wBAAwB,CAgCvF;AA+KD,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE,oBAAoB,UAI7E;AAi1DD,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAkOpE;AAOD,wBAAgB,sBAAsB,CACpC,YAAY,GAAE,CAAC,QAAQ,EAAE;IAAE,IAAI,IAAI,IAAI,CAAA;CAAE,KAAK,IAAkC,GAC/E,OAAO,CAAC,IAAI,CAAC,CAiEf"}
1
+ {"version":3,"file":"noble-ble-handler.d.ts","sourceRoot":"","sources":["../src/noble-ble-handler.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAA+B,WAAW,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,KAAK,EAAE,wBAAwB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAiC1F,KAAK,mBAAmB,GAAG,KAAK,GAAG;IACjC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC;AAEF,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,mBAAmB,EAAE,aAAa,SAAK,+CAG3F;AAED,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,OAAO,GAAG,wBAAwB,CAgCvF;AA+KD,wBAAgB,+BAA+B,CAAC,OAAO,CAAC,EAAE,oBAAoB,UAI7E;AAm2DD,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,WAAW,GAAG,IAAI,CAkOpE;AAOD,wBAAgB,sBAAsB,CACpC,YAAY,GAAE,CAAC,QAAQ,EAAE;IAAE,IAAI,IAAI,IAAI,CAAA;CAAE,KAAK,IAAkC,GAC/E,OAAO,CAAC,IAAI,CAAC,CAiEf"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/hd-transport-electron",
3
- "version": "1.2.8-alpha.130",
3
+ "version": "1.2.8-alpha.132",
4
4
  "author": "OneKey",
5
5
  "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme",
6
6
  "license": "MIT",
@@ -25,9 +25,9 @@
25
25
  "electron-log": ">=4.0.0"
26
26
  },
27
27
  "dependencies": {
28
- "@onekeyfe/hd-core": "1.2.8-alpha.130",
29
- "@onekeyfe/hd-shared": "1.2.8-alpha.130",
30
- "@onekeyfe/hd-transport": "1.2.8-alpha.130",
28
+ "@onekeyfe/hd-core": "1.2.8-alpha.132",
29
+ "@onekeyfe/hd-shared": "1.2.8-alpha.132",
30
+ "@onekeyfe/hd-transport": "1.2.8-alpha.132",
31
31
  "@stoprocent/noble": "2.3.16",
32
32
  "p-retry": "^4.6.2"
33
33
  },
@@ -36,5 +36,5 @@
36
36
  "electron": "^25.0.0",
37
37
  "typescript": "^5.3.3"
38
38
  },
39
- "gitHead": "bacd066a15648891f9683586fcd06e063b98dfec"
39
+ "gitHead": "b8eef508535a14385e67317042fe187dc9b5f660"
40
40
  }
@@ -1015,6 +1015,241 @@ describe('Noble BLE process shutdown', () => {
1015
1015
  expect(jest.getTimerCount()).toBe(0);
1016
1016
  });
1017
1017
 
1018
+ test('keeps one native direct connection across repeated probe timeouts for the same device', async () => {
1019
+ jest.useFakeTimers({ doNotFake: ['performance', 'setImmediate'] });
1020
+ const { sdk, native, handlers } = await setup();
1021
+ // Use Noble's real exclusive listeners without loading its native bindings.
1022
+ const Noble = jest.requireActual('@stoprocent/noble/lib/noble');
1023
+ const direct = new Noble({ connect: jest.fn(), cancelConnect: jest.fn() });
1024
+ const connectAsync = jest.fn((id: string) => direct.connectAsync(id));
1025
+ const cancelConnect = jest.fn((id: string) => direct.cancelConnect(id));
1026
+ Object.assign(native, { connectAsync, cancelConnect });
1027
+ const deviceId = 'aabbccddeeff';
1028
+
1029
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1030
+ const connecting = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT)?.({}, deviceId);
1031
+ await flushCallbacks();
1032
+ jest.advanceTimersByTime(1500);
1033
+ await flushCallbacks();
1034
+ expect(connectAsync).toHaveBeenCalledTimes(1);
1035
+ jest.advanceTimersByTime(2000);
1036
+ await flushCallbacks();
1037
+ expect(await connecting).toMatchObject({
1038
+ success: false,
1039
+ error: { errorCode: HardwareErrorCode.DeviceNotFound },
1040
+ });
1041
+ jest.advanceTimersByTime(15_000);
1042
+ }
1043
+
1044
+ expect(cancelConnect).not.toHaveBeenCalled();
1045
+ const disposing = sdk.disposeNobleBleSupport();
1046
+ await flushCallbacks();
1047
+ expect(native.stop).toHaveBeenCalledTimes(1);
1048
+ await disposing;
1049
+ expect(cancelConnect).toHaveBeenCalledTimes(1);
1050
+ expect(direct.listenerCount(`connect:${deviceId}`)).toBe(0);
1051
+ expect(jest.getTimerCount()).toBe(0);
1052
+ });
1053
+
1054
+ test('cleans up native success arriving after the direct probe timeout', async () => {
1055
+ jest.useFakeTimers({ doNotFake: ['performance', 'setImmediate'] });
1056
+ const { sdk, native, handlers } = await setup();
1057
+ const Noble = jest.requireActual('@stoprocent/noble/lib/noble');
1058
+ const disconnect = jest.fn();
1059
+ const direct = new Noble({ connect: jest.fn(), cancelConnect: jest.fn(), disconnect });
1060
+ disconnect.mockImplementation((id: string) => direct._onDisconnect(id));
1061
+ const peripheral = direct._createPeripheral(
1062
+ 'aabbccddeeff',
1063
+ '',
1064
+ '',
1065
+ true,
1066
+ { localName: 'OneKey Pro' },
1067
+ 0,
1068
+ true
1069
+ );
1070
+ const cancelConnect = jest.fn((id: string) => direct.cancelConnect(id));
1071
+ Object.assign(native, {
1072
+ connectAsync: jest.fn((id: string) => direct.connectAsync(id)),
1073
+ cancelConnect,
1074
+ });
1075
+ const connecting = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT)?.({}, peripheral.id);
1076
+ await flushCallbacks();
1077
+ jest.advanceTimersByTime(1500);
1078
+ await flushCallbacks();
1079
+ jest.advanceTimersByTime(2000);
1080
+ await flushCallbacks();
1081
+ expect(await connecting).toMatchObject({ success: false });
1082
+
1083
+ direct._onConnect(peripheral.id, null);
1084
+ await flushCallbacks();
1085
+ expect(disconnect).toHaveBeenCalledTimes(1);
1086
+ expect(peripheral.state).toBe('disconnected');
1087
+ await sdk.disposeNobleBleSupport();
1088
+ expect(cancelConnect).not.toHaveBeenCalled();
1089
+ expect(native.stop).toHaveBeenCalledTimes(1);
1090
+ expect(jest.getTimerCount()).toBe(0);
1091
+ });
1092
+
1093
+ test('retries unavailable native IDs after cooldown without retaining connections', async () => {
1094
+ jest.useFakeTimers({ doNotFake: ['performance', 'setImmediate'] });
1095
+ const { sdk, native, handlers } = await setup();
1096
+ const Noble = jest.requireActual('@stoprocent/noble/lib/noble');
1097
+ const connect = jest.fn(() => {
1098
+ throw new Error('Peripheral could not be retrieved for connection');
1099
+ });
1100
+ const direct = new Noble({ connect, cancelConnect: jest.fn() });
1101
+ const cancelConnect = jest.fn((id: string) => direct.cancelConnect(id));
1102
+ Object.assign(native, {
1103
+ connectAsync: jest.fn((id: string) => direct.connectAsync(id)),
1104
+ cancelConnect,
1105
+ });
1106
+
1107
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1108
+ const connecting = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT)?.(
1109
+ {},
1110
+ 'aabbccddeeff'
1111
+ );
1112
+ await flushCallbacks();
1113
+ jest.advanceTimersByTime(1500);
1114
+ await flushCallbacks();
1115
+ expect(await connecting).toMatchObject({ success: false });
1116
+ expect(connect).toHaveBeenCalledTimes(attempt + 1);
1117
+ expect(jest.getTimerCount()).toBe(0);
1118
+ jest.advanceTimersByTime(15_000);
1119
+ }
1120
+
1121
+ const disposing = sdk.disposeNobleBleSupport();
1122
+ await flushCallbacks();
1123
+ expect(native.stop).toHaveBeenCalledTimes(1);
1124
+ await disposing;
1125
+ expect(cancelConnect).not.toHaveBeenCalled();
1126
+ expect(jest.getTimerCount()).toBe(0);
1127
+ });
1128
+
1129
+ test.each(['before', 'after'])(
1130
+ 'retries a direct connection that fails before discovery %s the probe timeout',
1131
+ async phase => {
1132
+ jest.useFakeTimers({ doNotFake: ['performance', 'setImmediate'] });
1133
+ const { sdk, native, handlers } = await setup();
1134
+ const Noble = jest.requireActual('@stoprocent/noble/lib/noble');
1135
+ const bindings = Object.assign(new EventEmitter(), {
1136
+ connect: jest.fn(),
1137
+ cancelConnect: jest.fn(),
1138
+ });
1139
+ const direct = new Noble(bindings);
1140
+ direct._registerListeners();
1141
+ direct.removeAllListeners('warning');
1142
+ direct.on('warning', jest.fn());
1143
+ const connectAsync = jest.fn((id: string) => direct.connectAsync(id));
1144
+ Object.assign(native, {
1145
+ connectAsync,
1146
+ cancelConnect: jest.fn((id: string) => direct.cancelConnect(id)),
1147
+ });
1148
+ const deviceId = 'aabbccddeeff';
1149
+ let firstFinished = false;
1150
+ const connecting = Promise.resolve(
1151
+ handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT)?.({}, deviceId)
1152
+ ).then(result => {
1153
+ firstFinished = true;
1154
+ return result;
1155
+ });
1156
+ await flushCallbacks();
1157
+ jest.advanceTimersByTime(1500);
1158
+ await flushCallbacks();
1159
+ expect(connectAsync).toHaveBeenCalledTimes(1);
1160
+ expect(direct._peripherals.has(deviceId)).toBe(false);
1161
+ if (phase === 'after') {
1162
+ jest.advanceTimersByTime(2000);
1163
+ await flushCallbacks();
1164
+ }
1165
+ bindings.emit('connect', deviceId, new Error('native async connection failed'));
1166
+ await flushCallbacks();
1167
+ expect(firstFinished).toBe(true);
1168
+ expect(await connecting).toMatchObject({ success: false });
1169
+
1170
+ jest.advanceTimersByTime(15_000);
1171
+ const retry = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT)?.({}, deviceId);
1172
+ await flushCallbacks();
1173
+ jest.advanceTimersByTime(1500);
1174
+ await flushCallbacks();
1175
+ expect(connectAsync).toHaveBeenCalledTimes(2);
1176
+ const disposing = sdk.disposeNobleBleSupport();
1177
+ await flushCallbacks();
1178
+ expect(native.stop).toHaveBeenCalledTimes(1);
1179
+ await disposing;
1180
+ expect(await retry).toMatchObject({ success: false });
1181
+ expect(jest.getTimerCount()).toBe(0);
1182
+ }
1183
+ );
1184
+
1185
+ test('shares concurrent connections instead of cancelling a newer direct probe', async () => {
1186
+ jest.useFakeTimers({ doNotFake: ['performance', 'setImmediate'] });
1187
+ const { sdk, native, handlers } = await setup();
1188
+ const Noble = jest.requireActual('@stoprocent/noble/lib/noble');
1189
+ const direct = new Noble({ connect: jest.fn(), cancelConnect: jest.fn() });
1190
+ const connectAsync = jest.fn((id: string) => direct.connectAsync(id));
1191
+ const cancelConnect = jest.fn((id: string) => direct.cancelConnect(id));
1192
+ Object.assign(native, { connectAsync, cancelConnect });
1193
+ const connect = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT);
1194
+ const first = connect?.({}, 'aabbccddeeff');
1195
+ await flushCallbacks();
1196
+ jest.advanceTimersByTime(1500);
1197
+ await flushCallbacks();
1198
+ jest.advanceTimersByTime(500);
1199
+ const second = connect?.({}, 'aabbccddeeff');
1200
+ await flushCallbacks();
1201
+ expect(native.startScanning).toHaveBeenCalledTimes(1);
1202
+ jest.advanceTimersByTime(1500);
1203
+ await flushCallbacks();
1204
+ expect(await first).toMatchObject({ success: false });
1205
+ expect(await second).toMatchObject({ success: false });
1206
+ expect(connectAsync).toHaveBeenCalledTimes(1);
1207
+ expect(cancelConnect).not.toHaveBeenCalled();
1208
+
1209
+ const disposing = sdk.disposeNobleBleSupport();
1210
+ await flushCallbacks();
1211
+ expect(native.stop).toHaveBeenCalledTimes(1);
1212
+ await disposing;
1213
+ expect(cancelConnect).toHaveBeenCalledTimes(1);
1214
+ expect(jest.getTimerCount()).toBe(0);
1215
+ });
1216
+
1217
+ test('keeps late direct connect cleanup without native cancellation support', async () => {
1218
+ jest.useFakeTimers({ doNotFake: ['performance', 'setImmediate'] });
1219
+ const { sdk, native, handlers } = await setup();
1220
+ const peripheral = Object.assign(new EventEmitter(), {
1221
+ ...createPeripheral('late-device', 'OneKey Pro'),
1222
+ disconnect: jest.fn(callback => callback?.()),
1223
+ });
1224
+ let finishConnect: ((value: typeof peripheral) => void) | undefined;
1225
+ Object.assign(native, {
1226
+ connectAsync: jest.fn(
1227
+ () =>
1228
+ new Promise<typeof peripheral>(resolve => {
1229
+ finishConnect = resolve;
1230
+ })
1231
+ ),
1232
+ });
1233
+ const connecting = handlers.get(EOneKeyBleMessageKeys.NOBLE_BLE_CONNECT)?.({}, peripheral.id);
1234
+ await flushCallbacks();
1235
+ jest.advanceTimersByTime(1500);
1236
+ await flushCallbacks();
1237
+ jest.advanceTimersByTime(2000);
1238
+ await flushCallbacks();
1239
+ expect(await connecting).toMatchObject({
1240
+ success: false,
1241
+ error: { errorCode: HardwareErrorCode.DeviceNotFound },
1242
+ });
1243
+
1244
+ peripheral.state = 'connected';
1245
+ finishConnect?.(peripheral);
1246
+ await flushCallbacks();
1247
+ expect(peripheral.disconnect).toHaveBeenCalledTimes(1);
1248
+ await sdk.disposeNobleBleSupport();
1249
+ expect(native.stop).toHaveBeenCalledTimes(1);
1250
+ expect(jest.getTimerCount()).toBe(0);
1251
+ });
1252
+
1018
1253
  test('cancels native connects and waits for disconnect before release', async () => {
1019
1254
  const { sdk, native, handlers } = await setup();
1020
1255
  await handlers.get(EOneKeyBleMessageKeys.BLE_AVAILABILITY_CHECK)?.({});
@@ -1698,11 +1698,13 @@ async function setupConnectionAndDiscoverServices(
1698
1698
  }
1699
1699
  }
1700
1700
 
1701
- // noble/mac never resolves for an id CoreBluetooth cannot retrieve.
1701
+ // Bound probes while a native backend is still waiting for the device.
1702
1702
  const DIRECT_CONNECT_TIMEOUT_MS = 2000;
1703
1703
  // Floor after a timeout: do not pay the probe on every attempt.
1704
1704
  const DIRECT_CONNECT_COOLDOWN_MS = 15_000;
1705
1705
  const directConnectCooldownUntil = new Map<string, number>();
1706
+ const pendingDirectNativeConnections = new Set<string>();
1707
+ const deviceConnectionPromises = new Map<string, Promise<void>>();
1706
1708
 
1707
1709
  /**
1708
1710
  * Connect straight by id, skipping the ~650ms targeted scan. Both native
@@ -1721,6 +1723,8 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1721
1723
  | null;
1722
1724
  if (!nobleInstance?.connectAsync) return undefined;
1723
1725
 
1726
+ // Retain the original listener and late cleanup until the native attempt settles.
1727
+ if (pendingDirectNativeConnections.has(deviceId)) return undefined;
1724
1728
  const cooldownUntil = directConnectCooldownUntil.get(deviceId) ?? 0;
1725
1729
  if (Date.now() < cooldownUntil) return undefined;
1726
1730
 
@@ -1730,18 +1734,21 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1730
1734
  try {
1731
1735
  // The late-orphan guard must attach to THIS pending connect.
1732
1736
  const directPromise = nobleInstance.connectAsync(deviceId);
1737
+ pendingDirectNativeConnections.add(deviceId);
1733
1738
  trackNativeConnection(
1734
- directPromise.then(async late => {
1735
- if ((!abandoned && !disposing) || nativeReleased) return;
1736
- const peripheral = late ?? discoveredDevices.get(deviceId);
1737
- if (peripheral?.state === 'connected' && !connectedDevices.has(deviceId)) {
1738
- peripheral.removeAllListeners('disconnect');
1739
- await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1740
- timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1741
- timeoutBehavior: 'resolve',
1742
- });
1743
- }
1744
- }),
1739
+ directPromise
1740
+ .then(async late => {
1741
+ if ((!abandoned && !disposing) || nativeReleased) return;
1742
+ const peripheral = late ?? discoveredDevices.get(deviceId);
1743
+ if (peripheral?.state === 'connected' && !connectedDevices.has(deviceId)) {
1744
+ peripheral.removeAllListeners('disconnect');
1745
+ await runBleCallbackOperation(callback => peripheral.disconnect(() => callback()), {
1746
+ timeoutMs: BLE_DISCONNECT_CONFIRM_TIMEOUT_MS,
1747
+ timeoutBehavior: 'resolve',
1748
+ });
1749
+ }
1750
+ })
1751
+ .finally(() => pendingDirectNativeConnections.delete(deviceId)),
1745
1752
  () => nobleInstance.cancelConnect?.(deviceId)
1746
1753
  );
1747
1754
  const raced = await Promise.race([
@@ -1781,7 +1788,18 @@ async function tryDirectConnectById(deviceId: string): Promise<Peripheral | unde
1781
1788
  }
1782
1789
 
1783
1790
  // Connect to device - supports both discovered and direct connection modes
1784
- async function connectDevice(deviceId: string, webContents: WebContents): Promise<void> {
1791
+ function connectDevice(deviceId: string, webContents: WebContents): Promise<void> {
1792
+ // Share the whole attempt so a scan fallback cannot race another caller's native probe.
1793
+ const pending = deviceConnectionPromises.get(deviceId);
1794
+ if (pending) return pending;
1795
+ const connecting = connectDeviceOnce(deviceId, webContents).finally(() => {
1796
+ deviceConnectionPromises.delete(deviceId);
1797
+ });
1798
+ deviceConnectionPromises.set(deviceId, connecting);
1799
+ return connecting;
1800
+ }
1801
+
1802
+ async function connectDeviceOnce(deviceId: string, webContents: WebContents): Promise<void> {
1785
1803
  await initializeNoble();
1786
1804
  logger?.info('[NobleBLE] Connect device request:', {
1787
1805
  deviceId,