@onekeyfe/hwk-desktop-noble-ble 1.2.3-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,900 @@
1
+ import {
2
+ THIRD_PARTY_BLE_CHANNELS,
3
+ THIRD_PARTY_BLE_DEVICE_TTL_MS,
4
+ THIRD_PARTY_BLE_POWER_ON_TIMEOUT_MS,
5
+ THIRD_PARTY_BLE_SCAN_DURATION_MS,
6
+ THIRD_PARTY_BLE_SCAN_IDLE_STOP_MS,
7
+ __require
8
+ } from "./chunk-RNZYQRXV.mjs";
9
+
10
+ // src/debugLog.ts
11
+ var REDACTED_KEYS = /* @__PURE__ */ new Set([
12
+ "credential",
13
+ "credentials",
14
+ "privateKey",
15
+ "publicKey",
16
+ "hostKey",
17
+ "pin",
18
+ "passphrase",
19
+ "hexData",
20
+ "payload",
21
+ "bytes"
22
+ ]);
23
+ function redactBleDebugLogData(data) {
24
+ if (!data) return void 0;
25
+ const out = {};
26
+ for (const [key, value] of Object.entries(data)) {
27
+ out[key] = REDACTED_KEYS.has(key) ? "[redacted]" : value;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ // src/NobleBleHandler.ts
33
+ var DEFAULT_NOBLE_FACTORY = () => {
34
+ const noble = __require("@stoprocent/noble");
35
+ return noble;
36
+ };
37
+ var normalizeUuid = (uuid) => uuid.replace(/-/g, "").toLowerCase();
38
+ var matchesPeripheral = (p, match) => {
39
+ if (!match) return false;
40
+ const adv = p.advertisement ?? {};
41
+ const advertised = adv.serviceUuids ?? [];
42
+ if (match.serviceUuids?.length && advertised.some(
43
+ (uuid) => match.serviceUuids?.some((wanted) => normalizeUuid(wanted) === normalizeUuid(uuid))
44
+ )) {
45
+ return true;
46
+ }
47
+ const name = adv.localName;
48
+ if (!match.namePatterns?.length || !name) return false;
49
+ return match.namePatterns.every((pattern) => new RegExp(pattern, "i").test(name));
50
+ };
51
+ var peripheralToInfo = (p) => {
52
+ const adv = p.advertisement ?? {};
53
+ return {
54
+ id: p.id,
55
+ name: adv.localName,
56
+ localName: adv.localName,
57
+ rssi: p.rssi,
58
+ isConnectable: p.connectable ?? null,
59
+ advertisedServiceUuids: adv.serviceUuids,
60
+ serviceSolicitationUuids: adv.serviceSolicitationUuids,
61
+ txPowerLevel: adv.txPowerLevel,
62
+ manufacturerDataHex: adv.manufacturerData ? Buffer.from(adv.manufacturerData).toString("hex") : void 0,
63
+ serviceData: adv.serviceData?.map((entry) => ({
64
+ uuid: entry.uuid,
65
+ dataHex: Buffer.from(entry.data).toString("hex")
66
+ })),
67
+ address: p.address,
68
+ addressType: p.addressType,
69
+ state: p.state
70
+ };
71
+ };
72
+ var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
73
+ var BLE_CONNECT_SETTLE_MS = 300;
74
+ var BLE_CONNECT_TIMEOUT_MS = 31e3;
75
+ var BLE_DISCONNECT_TIMEOUT_MS = 2e3;
76
+ var NOBLE_RECOVER_COOLDOWN_MS = 1e4;
77
+ var NobleBleHandler = class {
78
+ constructor(options = {}) {
79
+ this._discovered = /* @__PURE__ */ new Map();
80
+ // id -> last advertisement time (snapshot TTL).
81
+ this._lastSeen = /* @__PURE__ */ new Map();
82
+ this._connected = /* @__PURE__ */ new Map();
83
+ this._scanning = false;
84
+ this._initialized = false;
85
+ this._disposed = false;
86
+ this._nobleInstances = /* @__PURE__ */ new Set();
87
+ this._pendingCancellations = /* @__PURE__ */ new Set();
88
+ this._connectAttempts = /* @__PURE__ */ new Set();
89
+ this._nativeReleased = false;
90
+ this._factory = options.nobleFactory ?? DEFAULT_NOBLE_FACTORY;
91
+ this._connectTimeoutMs = options.connectTimeoutMs ?? BLE_CONNECT_TIMEOUT_MS;
92
+ this._logger = options.logger;
93
+ }
94
+ setNotificationListener(handler) {
95
+ this._onNotification = handler;
96
+ }
97
+ setDisconnectedListener(handler) {
98
+ this._onDeviceDisconnected = handler;
99
+ }
100
+ async init() {
101
+ this._assertActive();
102
+ if (this._initialized) return;
103
+ if (this._initPromise) return this._initPromise;
104
+ this._initPromise = (async () => {
105
+ this._noble ?? (this._noble = this._factory());
106
+ this._nobleInstances.add(this._noble);
107
+ this._discoverHandler ?? (this._discoverHandler = (peripheral) => {
108
+ this._discovered.set(peripheral.id, peripheral);
109
+ this._lastSeen.set(peripheral.id, Date.now());
110
+ });
111
+ this._noble.removeListener("discover", this._discoverHandler);
112
+ this._noble.on("discover", this._discoverHandler);
113
+ await this._waitForPoweredOn(THIRD_PARTY_BLE_POWER_ON_TIMEOUT_MS);
114
+ this._assertActive();
115
+ this._initialized = true;
116
+ })();
117
+ try {
118
+ await this._initPromise;
119
+ } finally {
120
+ this._initPromise = void 0;
121
+ }
122
+ }
123
+ async checkAvailability() {
124
+ this._assertActive();
125
+ if (!this._noble) {
126
+ try {
127
+ this._noble = this._factory();
128
+ this._nobleInstances.add(this._noble);
129
+ } catch {
130
+ return { available: false, state: "unsupported", initialized: false };
131
+ }
132
+ }
133
+ const state = this._noble?.state ?? "unknown";
134
+ return {
135
+ available: state === "poweredOn",
136
+ state,
137
+ initialized: this._initialized
138
+ };
139
+ }
140
+ /**
141
+ * Lazy-start a continuous scan and return the current snapshot immediately.
142
+ *
143
+ * Scans UNFILTERED and applies the caller's match criteria in `_snapshot()` instead. A
144
+ * service-UUID filter cannot be used here: noble's Windows backend applies it
145
+ * per RECEIVED PACKET (`BLEManager::OnScanResult`, lib/win/src/ble_manager.cc),
146
+ * and a Safe 7's ADV packet carries only its name — the service UUID lives in
147
+ * the scan response, which arrives as a separate, irregularly-timed event. So
148
+ * a filtered scan drops every ADV packet and the device appears to be
149
+ * undiscoverable for minutes at a time while it is plainly on air. (OneKey's
150
+ * own devices do advertise their service UUID, which is why the same filter is
151
+ * safe in `hd-transport-electron` and was copied here by mistake.)
152
+ */
153
+ async scan(options) {
154
+ await this.init();
155
+ if (!this._scanning) {
156
+ this._scanning = true;
157
+ try {
158
+ await this._requireNoble().startScanningAsync([], true);
159
+ this._log("warn", "scan.start", {
160
+ ignoredServiceUuids: options?.serviceUuids,
161
+ allowDuplicates: true
162
+ });
163
+ } catch (error) {
164
+ this._scanning = false;
165
+ this._log("warn", "scan.start.error", { error: String(error) });
166
+ await this._recoverNobleIfStuck(String(error));
167
+ }
168
+ }
169
+ this._assertActive();
170
+ this._armIdleStop();
171
+ const devices = this._snapshot(options);
172
+ if (devices.length === 0) {
173
+ this._log("warn", "scan.empty", {
174
+ raw: this._discovered.size,
175
+ kept: 0,
176
+ named: [...this._discovered.values()].filter((p) => p.advertisement?.localName).length
177
+ });
178
+ }
179
+ return devices;
180
+ }
181
+ /**
182
+ * Current in-range devices for the asking vendor, dropping any that aged past
183
+ * the liveness TTL. The match test replaces the service-UUID scan filter we cannot use
184
+ * (see `scan`): it matches the name from the ADV packet, or the service UUID
185
+ * once a scan response has merged into the same peripheral.
186
+ *
187
+ * Note the TTL only prunes what the CALLER sees. `_discovered` is a cache, not
188
+ * the source of truth for reachability — a device missing from here can still
189
+ * be connected to by id (`_directConnect`).
190
+ */
191
+ _snapshot(options) {
192
+ const now = Date.now();
193
+ const result = [];
194
+ for (const [id, peripheral] of this._discovered) {
195
+ if (now - (this._lastSeen.get(id) ?? 0) > THIRD_PARTY_BLE_DEVICE_TTL_MS) {
196
+ this._discovered.delete(id);
197
+ this._lastSeen.delete(id);
198
+ continue;
199
+ }
200
+ const match = options?.match ?? { serviceUuids: options?.serviceUuids };
201
+ if (!matchesPeripheral(peripheral, match)) continue;
202
+ result.push(peripheralToInfo(peripheral));
203
+ }
204
+ for (const [id, entry] of this._connected) {
205
+ if (entry.vendor !== options?.vendor) continue;
206
+ if (result.some((info) => info.id === id)) continue;
207
+ result.push(peripheralToInfo(entry.peripheral));
208
+ }
209
+ return result;
210
+ }
211
+ /** A noble instance with its own bindings, so a stuck one can be replaced. */
212
+ _createFreshNoble() {
213
+ const candidate = this._factory();
214
+ return typeof candidate.withBindings === "function" ? candidate.withBindings() : candidate;
215
+ }
216
+ /**
217
+ * Rebuild noble when its adapter state is stuck. Re-enumerating the Windows
218
+ * BLE stack (pairing, or removing the device from OS settings) can catch
219
+ * noble's RadioWatcher mid-churn: it latches `unsupported` and never
220
+ * re-evaluates, so every later scan fails until the process restarts. Fresh
221
+ * bindings restart that watcher, which is the in-process equivalent of the
222
+ * app restart that is otherwise the only cure.
223
+ */
224
+ async _recoverNobleIfStuck(reason) {
225
+ if (this._disposed) return;
226
+ const state = this._noble?.state;
227
+ if (state === "poweredOn" || !this._initialized) return;
228
+ if (this._connected.size > 0) {
229
+ this._log("warn", "noble.recover.skip", {
230
+ reason,
231
+ state,
232
+ connected: this._connected.size
233
+ });
234
+ return;
235
+ }
236
+ const now = Date.now();
237
+ if (this._lastNobleRecoverAt && now - this._lastNobleRecoverAt < NOBLE_RECOVER_COOLDOWN_MS) {
238
+ return;
239
+ }
240
+ this._lastNobleRecoverAt = now;
241
+ this._log("warn", "noble.recover.start", { reason, state });
242
+ try {
243
+ const previous = this._noble;
244
+ if (previous && this._discoverHandler) {
245
+ previous.removeListener("discover", this._discoverHandler);
246
+ }
247
+ const fresh = this._createFreshNoble();
248
+ this._noble = fresh;
249
+ this._nobleInstances.add(fresh);
250
+ if (this._discoverHandler) {
251
+ fresh.on("discover", this._discoverHandler);
252
+ }
253
+ this._scanning = false;
254
+ this._discovered.clear();
255
+ this._lastSeen.clear();
256
+ await this._waitForPoweredOn(THIRD_PARTY_BLE_POWER_ON_TIMEOUT_MS);
257
+ this._log("warn", "noble.recover.done", { state: this._noble?.state });
258
+ } catch (error) {
259
+ this._log("warn", "noble.recover.error", { error: String(error) });
260
+ }
261
+ }
262
+ _armIdleStop() {
263
+ if (this._disposed) return;
264
+ this._clearIdleStop();
265
+ this._idleStopTimer = setTimeout(() => {
266
+ void this._stopContinuousScan();
267
+ }, THIRD_PARTY_BLE_SCAN_IDLE_STOP_MS);
268
+ }
269
+ _clearIdleStop() {
270
+ if (this._idleStopTimer) {
271
+ clearTimeout(this._idleStopTimer);
272
+ this._idleStopTimer = void 0;
273
+ }
274
+ }
275
+ /** Stop scanning but keep the discovered cache (used before connect). */
276
+ async _pauseScan() {
277
+ this._clearIdleStop();
278
+ if (!this._scanning) return;
279
+ this._scanning = false;
280
+ await this._noble?.stopScanningAsync().catch(() => void 0);
281
+ }
282
+ /** Stop scanning and forget discovered devices (idle timeout / teardown). */
283
+ async _stopContinuousScan() {
284
+ await this._pauseScan();
285
+ this._discovered.clear();
286
+ this._lastSeen.clear();
287
+ }
288
+ /**
289
+ * Stops the process-wide scan no matter which vendor asked. Scan *results*
290
+ * are filtered per vendor, but the radio is not: one vendor's stopScan ends
291
+ * the other's discovery too. That is safe only because the adapter job queue
292
+ * serializes discovery across vendors, so two are never scanning at once.
293
+ * Anything that breaks that — a background presence probe, say — needs
294
+ * per-vendor refcounting here first.
295
+ */
296
+ async stopScan() {
297
+ await this._stopContinuousScan();
298
+ }
299
+ /**
300
+ * Look up a previously-scanned device by id (no extra BLE traffic).
301
+ * Returns null if the device hasn't been seen by a recent scan.
302
+ */
303
+ getDevice(id) {
304
+ const p = this._discovered.get(id);
305
+ if (!p) return null;
306
+ return peripheralToInfo(p);
307
+ }
308
+ /**
309
+ * Read the current RSSI (in dBm) for a connected peripheral. Requires
310
+ * the device to be connected — noble can't read RSSI off a scan-only
311
+ * peripheral. Falls back to the cached scan-time rssi when the noble
312
+ * peripheral doesn't expose updateRssiAsync.
313
+ */
314
+ async readRssi(id) {
315
+ const entry = this._requireEntry(id);
316
+ if (entry.peripheral.updateRssiAsync) {
317
+ return entry.peripheral.updateRssiAsync();
318
+ }
319
+ return entry.peripheral.rssi;
320
+ }
321
+ /**
322
+ * Abort the in-flight pairing flow: abandon a connect that is still running,
323
+ * stop scanning, disconnect every peripheral the host currently has open.
324
+ * Caller is responsible for surfacing the cancellation to the upper UI layer.
325
+ *
326
+ * Abandoning the connect is what actually ends the flow. Pairing happens
327
+ * inside connectAsync and the entry only reaches _connected after service
328
+ * discovery, so the loop below never sees the device being paired — without
329
+ * the abandon the caller waits out the full connect timeout, which is sized
330
+ * to the OS pairing window and so feels like a hang.
331
+ */
332
+ async cancelPairing() {
333
+ const attempt = this._activeConnect;
334
+ if (attempt) {
335
+ this._activeConnect = void 0;
336
+ attempt.abandon(new Error(`connect cancelled: ${attempt.id}`));
337
+ }
338
+ await this.stopScan();
339
+ for (const id of Array.from(this._connected.keys())) {
340
+ await this.disconnect(id).catch(() => void 0);
341
+ }
342
+ }
343
+ /**
344
+ * Scan for a specific peripheral id and resolve THE MOMENT it's discovered,
345
+ * stopping the scan immediately (don't wait out the full window). The fast
346
+ * reconnect path for a stored connectId when the device IS advertising.
347
+ *
348
+ * This used to be the only reconnect path, on two assumptions that are both
349
+ * false: that noble cannot connect by id without a scan (it can — see
350
+ * `_directConnect`), and that "the device advertises continuously" (a bonded
351
+ * Safe 7 does not — it holds the link and goes silent). Callers must fall
352
+ * back to `_directConnect` when this returns undefined.
353
+ */
354
+ async _scanUntilFound(id, timeoutMs) {
355
+ await this.init();
356
+ const existing = this._discovered.get(id);
357
+ if (existing) return existing;
358
+ const noble = this._requireNoble();
359
+ return new Promise((resolve) => {
360
+ let done = false;
361
+ const finish = (p) => {
362
+ if (done) return;
363
+ done = true;
364
+ this._pendingCancellations.delete(cancel);
365
+ clearTimeout(timer);
366
+ noble.removeListener("discover", onDiscover);
367
+ void noble.stopScanningAsync().catch(() => void 0);
368
+ resolve(p);
369
+ };
370
+ const onDiscover = (peripheral) => {
371
+ this._discovered.set(peripheral.id, peripheral);
372
+ if (peripheral.id === id) finish(peripheral);
373
+ };
374
+ const timer = setTimeout(() => finish(this._discovered.get(id)), timeoutMs);
375
+ const cancel = () => finish();
376
+ this._pendingCancellations.add(cancel);
377
+ noble.on("discover", onDiscover);
378
+ void noble.startScanningAsync([], false).catch(() => finish());
379
+ });
380
+ }
381
+ /**
382
+ * Connect by id with no scan and no advertisement.
383
+ *
384
+ * This is the ONLY path that reaches a device which is connected but silent.
385
+ * A linked peripheral stops advertising while it HOLDS A LINK (standard BLE; a Safe 7's
386
+ * screen says "wait connection") — field-verified: bonding/THP handshake
387
+ * alone does NOT silence it, holding the connection does. So while a link is
388
+ * up, no amount of scanning will rediscover it — `_scanUntilFound` alone
389
+ * dead-ends with "device not found" on a device that is sitting right there,
390
+ * connected and reachable.
391
+ *
392
+ * noble supports this: `noble.connectAsync(id)` needs no prior `discover`,
393
+ * because both native backends materialize the peripheral themselves (Windows
394
+ * synthesizes one for an unknown address, macOS retrieves it by identifier)
395
+ * and then emit a `discover`, which our own handler turns back into a
396
+ * `_discovered` entry. OneKey's own noble handler calls this "direct
397
+ * connection mode"; Trezor Suite's equivalent is asking the adapter for its
398
+ * peripheral list instead of keeping a cache.
399
+ *
400
+ * Returns undefined (not throw) so the caller reports the normal
401
+ * "device not found" rather than a confusing noble-internal error.
402
+ */
403
+ async _directConnect(id) {
404
+ const noble = this._requireNoble();
405
+ if (typeof noble.connectAsync !== "function") {
406
+ this._log("warn", "connect.direct.unavailable", { id });
407
+ return void 0;
408
+ }
409
+ this._log("warn", "connect.direct.start", { id });
410
+ const startedAt = Date.now();
411
+ try {
412
+ const peripheral = await noble.connectAsync(id);
413
+ const resolved = peripheral ?? this._discovered.get(id);
414
+ this._log("warn", "connect.direct.done", {
415
+ id,
416
+ elapsedMs: Date.now() - startedAt,
417
+ found: Boolean(resolved),
418
+ // The one field that says whether the fix actually worked: an open link,
419
+ // or merely an object. Anything other than 'connected' is a failure that
420
+ // would otherwise surface later as a confusing service-discovery error.
421
+ state: resolved?.state,
422
+ fromNoble: Boolean(peripheral)
423
+ });
424
+ return resolved;
425
+ } catch (error) {
426
+ this._log("warn", "connect.direct.error", {
427
+ id,
428
+ elapsedMs: Date.now() - startedAt,
429
+ error: String(error)
430
+ });
431
+ return void 0;
432
+ }
433
+ }
434
+ // noble's disconnectAsync hangs on a peripheral whose connect just failed (it
435
+ // waits for a CoreBluetooth disconnect event that never comes); bound it so a
436
+ // cleanup disconnect can't hang the connect flow.
437
+ async _safeDisconnect(peripheral) {
438
+ if (this._nativeReleased) return;
439
+ let timeout;
440
+ try {
441
+ await Promise.race([
442
+ peripheral.disconnectAsync().catch(() => void 0),
443
+ new Promise((resolve) => {
444
+ timeout = setTimeout(resolve, BLE_DISCONNECT_TIMEOUT_MS);
445
+ })
446
+ ]);
447
+ } finally {
448
+ clearTimeout(timeout);
449
+ }
450
+ }
451
+ // noble has no connect timeout, so a stale bond hangs anywhere — connectAsync
452
+ // OR the post-connect (encrypted) service discovery. One overall timeout
453
+ // covers the whole flow. Two distinct failures reach the connector: a `timed
454
+ // out` reject (device unreachable) vs a connectAsync `connection failed`
455
+ // reject (link refused / stale bond) — mapped to different error codes there.
456
+ async connect(id, options) {
457
+ if (!options || !options.vendor || ![options.serviceUuid, options.writeUuid, options.notifyUuid].every(
458
+ (uuid) => typeof uuid === "string" && /^[0-9a-f]{32}$/i.test(normalizeUuid(uuid))
459
+ )) {
460
+ throw new Error("Invalid BLE GATT profile");
461
+ }
462
+ this._assertActive();
463
+ const claim = { abandoned: false };
464
+ let abandon;
465
+ const abandoned = new Promise((_, reject) => {
466
+ abandon = (error) => {
467
+ claim.abandoned = true;
468
+ reject(error);
469
+ };
470
+ });
471
+ const timer = setTimeout(
472
+ () => abandon(new Error(`connect timed out after ${this._connectTimeoutMs}ms`)),
473
+ this._connectTimeoutMs
474
+ );
475
+ const attempt = {
476
+ id,
477
+ abandon,
478
+ cancelNative: () => claim.cancelNative?.(),
479
+ settled: Promise.resolve(void 0)
480
+ };
481
+ this._activeConnect = attempt;
482
+ this._connectAttempts.add(attempt);
483
+ const nativeOperation = this._connectInner(id, claim, options);
484
+ const caller = (async () => {
485
+ try {
486
+ return await Promise.race([nativeOperation, abandoned]);
487
+ } catch (error) {
488
+ const peripheral = this._discovered.get(id);
489
+ if (peripheral) await this._safeDisconnect(peripheral);
490
+ throw error;
491
+ } finally {
492
+ clearTimeout(timer);
493
+ if (this._activeConnect === attempt) this._activeConnect = void 0;
494
+ }
495
+ })();
496
+ attempt.settled = Promise.allSettled([nativeOperation, caller]).finally(() => {
497
+ this._connectAttempts.delete(attempt);
498
+ });
499
+ return caller;
500
+ }
501
+ async _connectInner(id, claim, options) {
502
+ await this.init();
503
+ await this._pauseScan();
504
+ await delay(BLE_CONNECT_SETTLE_MS);
505
+ this._assertActive();
506
+ let route = "cache";
507
+ let peripheral = this._discovered.get(id);
508
+ if (!peripheral) {
509
+ route = "scan";
510
+ peripheral = await this._scanUntilFound(id, THIRD_PARTY_BLE_SCAN_DURATION_MS);
511
+ }
512
+ if (!peripheral) {
513
+ route = "direct";
514
+ }
515
+ if (!peripheral) {
516
+ const native2 = this._requireNoble();
517
+ claim.cancelNative = () => native2.cancelConnect?.(id);
518
+ peripheral = await this._directConnect(id);
519
+ }
520
+ if (!peripheral) {
521
+ this._log("warn", "connect.notFound", {
522
+ id,
523
+ route: "none",
524
+ discoveredCount: this._discovered.size
525
+ });
526
+ throw new Error(`BLE device not found: ${id}`);
527
+ }
528
+ const abortIfAbandoned = async (stage) => {
529
+ if (!claim.abandoned && !this._disposed) return;
530
+ if (peripheral && peripheral.state === "connected" && !this._connected.has(id)) {
531
+ await this._safeDisconnect(peripheral);
532
+ }
533
+ this._log("warn", "connect.abandoned", { id, route, stage });
534
+ throw new Error(`connect abandoned after timeout: ${id}`);
535
+ };
536
+ await abortIfAbandoned("resolve");
537
+ const wasConnected = peripheral.state === "connected";
538
+ const connectingPeripheral = peripheral;
539
+ const native = this._requireNoble();
540
+ claim.cancelNative = () => {
541
+ if (connectingPeripheral.state === "connecting" && connectingPeripheral.cancelConnect) {
542
+ connectingPeripheral.cancelConnect();
543
+ } else {
544
+ native.cancelConnect?.(id);
545
+ }
546
+ };
547
+ this._log("warn", "connect.route", {
548
+ id,
549
+ route,
550
+ wasConnected,
551
+ name: peripheral.advertisement?.localName
552
+ });
553
+ if (!wasConnected) {
554
+ await peripheral.connectAsync();
555
+ await abortIfAbandoned("link");
556
+ }
557
+ try {
558
+ const uuids = {
559
+ service: options.serviceUuid,
560
+ write: options.writeUuid,
561
+ notify: options.notifyUuid
562
+ };
563
+ const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync(
564
+ [uuids.service],
565
+ [uuids.write, uuids.notify]
566
+ );
567
+ const writeUuid = normalizeUuid(uuids.write);
568
+ const notifyUuid = normalizeUuid(uuids.notify);
569
+ const writeChar = characteristics.find((c) => normalizeUuid(c.uuid) === writeUuid);
570
+ const notifyChar = characteristics.find((c) => normalizeUuid(c.uuid) === notifyUuid);
571
+ if (!writeChar || !notifyChar) {
572
+ throw new Error(`BLE characteristics not found on device ${id}`);
573
+ }
574
+ await abortIfAbandoned("discovery");
575
+ const disconnectHandler = () => {
576
+ if (this._connected.get(id)?.disconnectHandler !== disconnectHandler) return;
577
+ this._cleanupDevice(
578
+ id,
579
+ /* unexpected */
580
+ true
581
+ );
582
+ };
583
+ peripheral.on("disconnect", disconnectHandler);
584
+ this._connected.set(id, {
585
+ peripheral,
586
+ writeChar,
587
+ notifyChar,
588
+ disconnectHandler,
589
+ vendor: options.vendor,
590
+ write: options.write
591
+ });
592
+ this._log("info", "connect.done", { id, name: peripheral.advertisement.localName });
593
+ return { id, name: peripheral.advertisement.localName };
594
+ } catch (error) {
595
+ if (!wasConnected) await this._safeDisconnect(peripheral);
596
+ throw error;
597
+ }
598
+ }
599
+ async disconnect(id) {
600
+ const entry = this._connected.get(id);
601
+ if (!entry) return;
602
+ if (entry.disconnectHandler) {
603
+ entry.peripheral.removeListener("disconnect", entry.disconnectHandler);
604
+ }
605
+ try {
606
+ await entry.peripheral.disconnectAsync();
607
+ } catch (error) {
608
+ this._log("warn", "disconnect.error", { id, error: String(error) });
609
+ }
610
+ this._cleanupDevice(
611
+ id,
612
+ /* unexpected */
613
+ false
614
+ );
615
+ }
616
+ async subscribe(id) {
617
+ const entry = this._requireEntry(id);
618
+ if (!entry.notifyChar) throw new Error(`BLE notify characteristic missing for ${id}`);
619
+ if (entry.notifyHandler) return;
620
+ const handler = (data) => {
621
+ this._onNotification?.(id, data.toString("hex"));
622
+ };
623
+ entry.notifyHandler = handler;
624
+ entry.notifyChar.on("data", handler);
625
+ await entry.notifyChar.subscribeAsync();
626
+ }
627
+ async unsubscribe(id) {
628
+ const entry = this._connected.get(id);
629
+ if (!entry?.notifyChar) return;
630
+ if (entry.notifyHandler) {
631
+ entry.notifyChar.removeListener("data", entry.notifyHandler);
632
+ entry.notifyHandler = void 0;
633
+ }
634
+ try {
635
+ await entry.notifyChar.unsubscribeAsync();
636
+ } catch (error) {
637
+ this._log("warn", "unsubscribe.error", { id, error: String(error) });
638
+ }
639
+ }
640
+ async write(id, hexData) {
641
+ const entry = this._requireEntry(id);
642
+ if (!entry.writeChar) throw new Error(`BLE write characteristic missing for ${id}`);
643
+ const buffer = Buffer.from(hexData, "hex");
644
+ const framing = entry.write;
645
+ if (!framing) throw new Error(`No BLE write framing recorded for ${id}`);
646
+ if (framing.mode === "raw") {
647
+ const maxLength = framing.maxLength ?? buffer.length;
648
+ if (!/^(?:[0-9a-f]{2})+$/i.test(hexData) || buffer.length > maxLength) {
649
+ throw new Error(`Invalid BLE frame for ${entry.vendor ?? "device"}`);
650
+ }
651
+ await entry.writeChar.writeAsync(buffer, false);
652
+ return;
653
+ }
654
+ const chunkSize = framing.chunkSize;
655
+ if (!chunkSize) throw new Error(`Padded BLE writes need a chunkSize for ${id}`);
656
+ for (let offset = 0; offset < buffer.length; offset += chunkSize) {
657
+ this._assertActive();
658
+ const slice = buffer.subarray(offset, offset + chunkSize);
659
+ const chunk = Buffer.alloc(chunkSize);
660
+ slice.copy(chunk);
661
+ await entry.writeChar.writeAsync(chunk, false);
662
+ if (offset + chunkSize < buffer.length && framing.chunkDelayMs) {
663
+ await delay(framing.chunkDelayMs);
664
+ }
665
+ }
666
+ }
667
+ /** Retire a renderer's handler without stopping a process-wide native manager. */
668
+ dispose() {
669
+ if (this._disposePromise) return this._disposePromise;
670
+ this._disposed = true;
671
+ this._clearIdleStop();
672
+ this._scanning = false;
673
+ this._onNotification = void 0;
674
+ this._onDeviceDisconnected = void 0;
675
+ const connections = Array.from(this._connectAttempts);
676
+ for (const attempt of connections) {
677
+ attempt.abandon(new Error("Desktop BLE is shutting down"));
678
+ try {
679
+ attempt.cancelNative();
680
+ } catch (error) {
681
+ this._log("warn", "dispose.cancelConnect.error", { error: String(error) });
682
+ }
683
+ }
684
+ for (const cancel of this._pendingCancellations) cancel();
685
+ if (this._noble && this._discoverHandler) {
686
+ this._noble.removeListener("discover", this._discoverHandler);
687
+ }
688
+ const entries = Array.from(this._connected.entries());
689
+ for (const [id, entry] of entries) {
690
+ if (entry.disconnectHandler) {
691
+ entry.peripheral.removeListener("disconnect", entry.disconnectHandler);
692
+ }
693
+ this._cleanupDevice(id, false);
694
+ }
695
+ let timeout;
696
+ this._disposePromise = (async () => {
697
+ try {
698
+ await Promise.race([
699
+ Promise.allSettled([
700
+ ...connections.map((attempt) => attempt.settled),
701
+ ...Array.from(this._nobleInstances, async (instance) => instance.stopScanningAsync()),
702
+ ...entries.map(async ([, entry]) => {
703
+ let unsubscribeTimeout;
704
+ try {
705
+ await Promise.race([
706
+ entry.notifyChar?.unsubscribeAsync().catch(() => void 0),
707
+ new Promise((resolve) => {
708
+ unsubscribeTimeout = setTimeout(resolve, 250);
709
+ })
710
+ ]);
711
+ } finally {
712
+ clearTimeout(unsubscribeTimeout);
713
+ await this._safeDisconnect(entry.peripheral);
714
+ }
715
+ })
716
+ ]),
717
+ new Promise((resolve) => {
718
+ timeout = setTimeout(() => {
719
+ this._log("warn", "dispose.timeout");
720
+ resolve();
721
+ }, 3500);
722
+ })
723
+ ]);
724
+ } finally {
725
+ clearTimeout(timeout);
726
+ this._discovered.clear();
727
+ this._lastSeen.clear();
728
+ this._initialized = false;
729
+ }
730
+ })();
731
+ return this._disposePromise;
732
+ }
733
+ /**
734
+ * Terminal native release, including instances replaced by adapter recovery.
735
+ * A host sharing Noble must defer stop() until all transports have disposed,
736
+ * and deduplicate instances passed to releaseNoble across those transports.
737
+ */
738
+ disposeForAppQuit(releaseNoble = (instance) => instance.stop?.()) {
739
+ if (!this._releasePromise) {
740
+ this._releasePromise = this.dispose().finally(() => {
741
+ this._nativeReleased = true;
742
+ let releaseError;
743
+ for (const instance of this._nobleInstances) {
744
+ try {
745
+ releaseNoble(instance);
746
+ } catch (error) {
747
+ releaseError = error instanceof Error ? error : new Error(String(error));
748
+ }
749
+ }
750
+ this._nobleInstances.clear();
751
+ if (releaseError) throw releaseError;
752
+ this._log("info", "dispose.native.done");
753
+ });
754
+ }
755
+ return this._releasePromise;
756
+ }
757
+ _assertActive() {
758
+ if (this._disposed) throw new Error("Desktop BLE is shutting down");
759
+ }
760
+ _cleanupDevice(id, unexpected) {
761
+ const entry = this._connected.get(id);
762
+ if (!entry) return;
763
+ if (entry.notifyChar && entry.notifyHandler) {
764
+ entry.notifyChar.removeListener("data", entry.notifyHandler);
765
+ }
766
+ if (entry.disconnectHandler) {
767
+ entry.peripheral.removeListener("disconnect", entry.disconnectHandler);
768
+ }
769
+ this._connected.delete(id);
770
+ if (unexpected) {
771
+ this._log("warn", "disconnect.unexpected", { id });
772
+ this._onDeviceDisconnected?.(id);
773
+ }
774
+ }
775
+ _requireEntry(id) {
776
+ this._assertActive();
777
+ const entry = this._connected.get(id);
778
+ if (!entry) throw new Error(`BLE device is not connected: ${id}`);
779
+ return entry;
780
+ }
781
+ _requireNoble() {
782
+ this._assertActive();
783
+ if (!this._noble) throw new Error("Desktop BLE: noble was not initialized");
784
+ return this._noble;
785
+ }
786
+ async _waitForPoweredOn(timeoutMs) {
787
+ const noble = this._requireNoble();
788
+ if (noble.state === "poweredOn") return;
789
+ await new Promise((resolve, reject) => {
790
+ const cleanup = () => {
791
+ clearTimeout(timer);
792
+ noble.removeListener("stateChange", handler);
793
+ this._pendingCancellations.delete(cancel);
794
+ };
795
+ const cancel = () => {
796
+ cleanup();
797
+ reject(new Error("Desktop BLE is shutting down"));
798
+ };
799
+ const timer = setTimeout(() => {
800
+ cleanup();
801
+ reject(
802
+ new Error(
803
+ `Desktop BLE: noble did not reach poweredOn within ${timeoutMs}ms (last state: ${noble.state})`
804
+ )
805
+ );
806
+ }, timeoutMs);
807
+ const handler = (state) => {
808
+ if (state === "poweredOn") {
809
+ cleanup();
810
+ resolve();
811
+ } else if (state === "unsupported" || state === "unauthorized") {
812
+ cleanup();
813
+ reject(new Error(`Desktop BLE: noble state ${state}`));
814
+ }
815
+ };
816
+ this._pendingCancellations.add(cancel);
817
+ noble.on("stateChange", handler);
818
+ });
819
+ }
820
+ _log(level, event, data) {
821
+ this._logger?.({
822
+ level,
823
+ scope: "desktop-noble-ble",
824
+ event,
825
+ data: redactBleDebugLogData(data)
826
+ });
827
+ }
828
+ };
829
+
830
+ // src/main.ts
831
+ var DEFAULT_IPC_MAIN = () => {
832
+ const { ipcMain } = __require("electron");
833
+ return ipcMain;
834
+ };
835
+ function initThirdPartyBleSupport(webContents, options = {}) {
836
+ const ipcMain = options.ipcMain ?? DEFAULT_IPC_MAIN();
837
+ const handler = new NobleBleHandler(options);
838
+ let disposed = false;
839
+ handler.setNotificationListener((id, hexData) => {
840
+ webContents.send(THIRD_PARTY_BLE_CHANNELS.notification, id, hexData);
841
+ });
842
+ handler.setDisconnectedListener((id) => {
843
+ webContents.send(THIRD_PARTY_BLE_CHANNELS.disconnected, id);
844
+ });
845
+ const handle = (channel, fn) => {
846
+ ipcMain.handle(channel, async (_event, ...args) => {
847
+ if (disposed) throw new Error("Third-party BLE is shutting down");
848
+ return fn(...args);
849
+ });
850
+ };
851
+ handle(
852
+ THIRD_PARTY_BLE_CHANNELS.scan,
853
+ (options2) => handler.scan(options2)
854
+ );
855
+ handle(THIRD_PARTY_BLE_CHANNELS.stopScan, () => handler.stopScan());
856
+ handle(
857
+ THIRD_PARTY_BLE_CHANNELS.connect,
858
+ (id, options2) => handler.connect(id, options2)
859
+ );
860
+ handle(THIRD_PARTY_BLE_CHANNELS.disconnect, (id) => handler.disconnect(id));
861
+ handle(
862
+ THIRD_PARTY_BLE_CHANNELS.write,
863
+ (id, hexData) => handler.write(id, hexData)
864
+ );
865
+ handle(THIRD_PARTY_BLE_CHANNELS.subscribe, (id) => handler.subscribe(id));
866
+ handle(THIRD_PARTY_BLE_CHANNELS.unsubscribe, (id) => handler.unsubscribe(id));
867
+ handle(THIRD_PARTY_BLE_CHANNELS.availability, () => handler.checkAvailability());
868
+ handle(THIRD_PARTY_BLE_CHANNELS.getDevice, (id) => handler.getDevice(id));
869
+ handle(THIRD_PARTY_BLE_CHANNELS.readRssi, (id) => handler.readRssi(id));
870
+ handle(THIRD_PARTY_BLE_CHANNELS.cancelPairing, () => handler.cancelPairing());
871
+ const removeHandlers = () => {
872
+ if (disposed) return;
873
+ disposed = true;
874
+ for (const channel of Object.values(THIRD_PARTY_BLE_CHANNELS)) {
875
+ ipcMain.removeHandler(channel);
876
+ }
877
+ };
878
+ return {
879
+ handler,
880
+ dispose: () => {
881
+ removeHandlers();
882
+ return handler.dispose();
883
+ },
884
+ disposeForAppQuit: (releaseNoble) => {
885
+ removeHandlers();
886
+ return handler.disposeForAppQuit(releaseNoble);
887
+ }
888
+ };
889
+ }
890
+ export {
891
+ NobleBleHandler,
892
+ THIRD_PARTY_BLE_CHANNELS,
893
+ THIRD_PARTY_BLE_DEVICE_TTL_MS,
894
+ THIRD_PARTY_BLE_POWER_ON_TIMEOUT_MS,
895
+ THIRD_PARTY_BLE_SCAN_DURATION_MS,
896
+ THIRD_PARTY_BLE_SCAN_IDLE_STOP_MS,
897
+ initThirdPartyBleSupport,
898
+ redactBleDebugLogData
899
+ };
900
+ //# sourceMappingURL=index.mjs.map