@cero-base/core 1.1.1 → 1.3.0

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.
@@ -0,0 +1,804 @@
1
+ import ReadyResource from 'ready-resource'
2
+ import b4a from 'b4a'
3
+ import { hash, randomBytes } from 'hypercore-crypto'
4
+ import safetyCatch from 'safety-catch'
5
+
6
+ import { GattStream } from './gatt-stream.js'
7
+
8
+ // One data characteristic (write + notify) carries a framed byte stream both
9
+ // ways. iOS never answers an L2CAP channel opened by a Mac central and gives no
10
+ // L2CAP disconnect signal; GATT with app-level framing is the proven transport
11
+ // (the bitchat mesh app uses exactly this on iOS and Android).
12
+ const DATA_UUID = 'ce1a0004-0000-1000-8000-00805f9b34fb'
13
+
14
+ // Wire frame on the characteristic (both directions): [type:1][sessionId:8][payload].
15
+ const TYPE_OPEN = 1
16
+ const TYPE_DATA = 2
17
+ const TYPE_CLOSE = 3
18
+ const TYPE_HELLO = 4
19
+ const SID_LEN = 8
20
+ const HEADER = 1 + SID_LEN
21
+
22
+ const DEFAULT_CAP = 4
23
+ const CONNECT_TIMEOUT = 15000
24
+ // per-peer dial backoff bases (bitchat model): eager while unlinked, patient once
25
+ // we have a link. The actual cooldown grows exponentially per consecutive failure.
26
+ const DIAL_COOLDOWN_BASE = 8000
27
+ const DIAL_COOLDOWN_BASE_LONELY = 2000
28
+ const DIAL_COOLDOWN_MAX = 30000
29
+ // no two dials globally closer than this — one radio can't usefully dial faster
30
+ const DIAL_MIN_INTERVAL = 500
31
+ // Lonely: scan continuously, cycling every SCAN_RESTART_LONELY so a re-advertised
32
+ // peer is re-reported (iOS suppresses duplicate discoveries within a scan session).
33
+ const SCAN_RESTART_LONELY = 5000
34
+ // Linked: continuous scanning is the dominant BLE battery cost, so duty-cycle it
35
+ // (bitchat model) — scan for SCAN_DUTY_ON, then stay dark for SCAN_DUTY_OFF.
36
+ const SCAN_DUTY_ON = 5000
37
+ const SCAN_DUTY_OFF = 25000
38
+ // suspend() drain window: how long to let goodbye frames flush before hanging up.
39
+ const DRAIN_MS = 300
40
+
41
+ const EMPTY = b4a.alloc(0)
42
+
43
+ /**
44
+ * Build a wire frame [type][sessionId][payload].
45
+ *
46
+ * @param {number} type
47
+ * @param {Uint8Array} sid 8-byte session id.
48
+ * @param {Uint8Array} [payload]
49
+ * @returns {Buffer}
50
+ */
51
+ function frame(type, sid, payload = EMPTY) {
52
+ const out = b4a.allocUnsafe(HEADER + payload.byteLength)
53
+ out[0] = type
54
+ b4a.copy(sid, out, 1)
55
+ if (payload.byteLength) b4a.copy(payload, out, HEADER)
56
+ return out
57
+ }
58
+
59
+ /**
60
+ * Parse a wire frame. Short/empty buffers → null (the caller logs + drops).
61
+ *
62
+ * @param {Uint8Array} buf
63
+ * @returns {{ type: number, sid: Uint8Array, sidHex: string, payload: Uint8Array } | null}
64
+ */
65
+ function parseFrame(buf) {
66
+ if (!buf || buf.byteLength < HEADER) return null
67
+ const sid = buf.subarray(1, HEADER)
68
+ return {
69
+ type: buf[0],
70
+ sid,
71
+ sidHex: b4a.toString(sid, 'hex'),
72
+ payload: buf.subarray(HEADER)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Derive a stable 128-bit BLE service UUID from a topic. Only devices that
78
+ * compute the same UUID (same channel / same invite) ever discover each other.
79
+ *
80
+ * @param {Uint8Array} topic
81
+ * @param {string} [tag] Namespace so channel and invite meshes never collide.
82
+ * @returns {string}
83
+ */
84
+ export function toServiceUUID(topic, tag = 'cero-ble') {
85
+ const h = hash([b4a.from(tag), topic])
86
+ const hex = b4a.toString(h.subarray(0, 16), 'hex')
87
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
88
+ }
89
+
90
+ // Backend adapter state → the app-facing state the facade exposes.
91
+ const STATE = {
92
+ poweredOn: 'on',
93
+ poweredOff: 'waiting',
94
+ unauthorized: 'unauthorized',
95
+ unsupported: 'unsupported',
96
+ resetting: 'waiting',
97
+ unknown: 'waiting'
98
+ }
99
+
100
+ const uuidEq = (a, b) =>
101
+ String(a || '')
102
+ .toLowerCase()
103
+ .replace(/-/g, '') ===
104
+ String(b || '')
105
+ .toLowerCase()
106
+ .replace(/-/g, '')
107
+
108
+ const findByUUID = (items, uuid) => (items || []).find((i) => uuidEq(i.uuid, uuid)) || null
109
+
110
+ /**
111
+ * Dual-role BLE transport: advertises + scans one service UUID, opens a GATT
112
+ * byte-stream to each discovered peer, and feeds it into `network.inject`. From
113
+ * there replication and pairing are transport-agnostic (see Network.inject).
114
+ *
115
+ * Choreography: the server adds one data characteristic (write + notify) and
116
+ * advertises. The central connects, discovers the characteristic, subscribes,
117
+ * then framed bytes flow both ways — central→server as GATT writes, server→
118
+ * central as notifications — each tagged with an 8-byte session id (bitchat
119
+ * model). `backend` is bare-bluetooth in production and a mock in tests.
120
+ *
121
+ * ponytail: capability-handshake DoS link-scoring is deferred — it needs a
122
+ * replication-progress signal (design §4b). v1 caps links + times out dials.
123
+ *
124
+ * @extends ReadyResource
125
+ */
126
+ export class BluetoothTransport extends ReadyResource {
127
+ /**
128
+ * @param {object} opts
129
+ * @param {any} opts.backend bare-bluetooth-shaped module (Central, Server, Service, Characteristic).
130
+ * @param {import('./index.js').Network} opts.network
131
+ * @param {Uint8Array} opts.uuid The 32-byte topic the service UUID derives from.
132
+ * @param {Uint8Array} opts.nodeId Stable local id (identity/device key) for the initiate tie-break.
133
+ * @param {string} [opts.tag] UUID namespace (channel mesh vs invite mesh).
134
+ * @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
135
+ * @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
136
+ * @param {boolean} [opts.keepLinks] On close, stop the radio but leave established links alive (invite rendezvous: the link outlives the QR and carries the initial replication).
137
+ * @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
138
+ */
139
+ constructor({
140
+ backend,
141
+ network,
142
+ uuid,
143
+ nodeId,
144
+ tag = 'cero-ble',
145
+ cap = DEFAULT_CAP,
146
+ scanOptions,
147
+ keepLinks = false,
148
+ name
149
+ }) {
150
+ super()
151
+ this.backend = backend
152
+ this.network = network
153
+ this.name = name || ''
154
+ this.nodeId = nodeId
155
+ this.nodeHex = b4a.toString(nodeId, 'hex')
156
+ this.serviceUUID = toServiceUUID(uuid, tag)
157
+ this.cap = cap
158
+ this.scanOptions = scanOptions
159
+ this.keepLinks = keepLinks
160
+
161
+ this.state = 'off'
162
+ this.central = null
163
+ this.server = null
164
+ this._dataChar = null
165
+ /** sessionId hex → { stream, sid } for server-side (peripheral) sessions */
166
+ this._sessions = new Map()
167
+ /** serialized server notify queue: { frame, resolve, reject } */
168
+ this._notifyQueue = []
169
+ this._scanning = false
170
+ this._advertising = false
171
+ this._serviceAdded = false
172
+ /** peripheral id being dialed → its connect-timeout timer */
173
+ this._dialing = new Map()
174
+ /** peripheral ids that carry a live channel — never re-dialed (a second
175
+ * dial's failure would disconnect the peripheral and kill the good link) */
176
+ this._linked = new Set()
177
+ /** peripheral id → retry-after timestamp; failed dials back off */
178
+ this._coolUntil = new Map()
179
+ /** peripheral id → consecutive failure count; drives exponential backoff */
180
+ this._failures = new Map()
181
+ /** peripheral id → remote peer key, learned at handshake — dial guard */
182
+ this._peerByPeripheral = new Map()
183
+ /** live central-side peripheral wrappers — for goodbye + physical hang-up on suspend */
184
+ this._connectedPeripherals = new Set()
185
+ /** last central.connect timestamp — global inter-dial rate limit */
186
+ this._lastDial = 0
187
+ this._scanTimer = null
188
+ this._suspended = false
189
+ /** live injected links keyed by remote node id hex */
190
+ this.peers = new Map()
191
+ }
192
+
193
+ /**
194
+ * Whether we should be the one to open the connection to `peerNodeId`. The
195
+ * lexicographically smaller id initiates; the larger waits — so a pair
196
+ * connects once, not twice. Equal (our own reflection) → false.
197
+ *
198
+ * @param {Uint8Array} peerNodeId
199
+ * @returns {boolean}
200
+ */
201
+ shouldInitiate(peerNodeId) {
202
+ return b4a.compare(this.nodeId, peerNodeId) < 0
203
+ }
204
+
205
+ get linkCount() {
206
+ return this.peers.size
207
+ }
208
+
209
+ async _open() {
210
+ const { Central, Server, Service, Characteristic } = this.backend
211
+
212
+ this.server = new Server()
213
+ this.server.on('stateChange', (s) => {
214
+ this._onState(s)
215
+ if (s === 'poweredOn') this._startServer(Service, Characteristic)
216
+ })
217
+ this.server.on('serviceAdd', () => {
218
+ this._serviceAdded = true
219
+ this._maybeAdvertise()
220
+ })
221
+ this.server.on('writeRequest', (reqs) => this._onWriteRequests(reqs))
222
+ // Notify queue drain signal: the last updateValue was refused (queue full);
223
+ // retry the head frame now that the peripheral can accept more.
224
+ this.server.on('readyToUpdate', () => this._drainNotify())
225
+ // WriteRequests carry no central identifier (see peripheral-manager
226
+ // _onwriterequests), so sessions can't be mapped to the unsubscribing
227
+ // central; teardown is left to the per-link keepalive/timeout liveness in
228
+ // _onChannel (15s).
229
+ this.server.on('unsubscribe', () => {})
230
+ this.server.on('error', safetyCatch)
231
+
232
+ this.central = new Central()
233
+ this.central.on('stateChange', (s) => {
234
+ this._onState(s)
235
+ if (s === 'poweredOn') this._startScan()
236
+ })
237
+ this.central.on('discover', (peripheral) => this._onDiscover(peripheral))
238
+ this.central.on('connect', (peripheral) => this._onConnect(peripheral))
239
+ this.central.on('disconnect', () => {})
240
+ this.central.on('error', (err) => this._onCentralError(err))
241
+
242
+ this._startServer(Service, Characteristic)
243
+ this._startScan()
244
+ this.state = 'starting'
245
+ }
246
+
247
+ _startServer(Service, Characteristic) {
248
+ if (this.server.state !== 'poweredOn') return // wait for the adapter (stateChange)
249
+ if (!this._serviceAdded) {
250
+ this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
251
+ this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
252
+ }
253
+ }
254
+
255
+ _maybeAdvertise() {
256
+ if (this._advertising || !this._serviceAdded) return
257
+ this._advertising = true
258
+ this.server.startAdvertising({ serviceUUIDs: [this.serviceUUID] })
259
+ }
260
+
261
+ // ─── server (peripheral) side ─────────────────────────────────────────────
262
+
263
+ _onWriteRequests(requests) {
264
+ const ok = this.server.constructor.ATT_SUCCESS ?? 0
265
+ for (const req of requests) {
266
+ // respond within ms or the central times out — before any parsing
267
+ if (req.responseNeeded !== false) this.server.respondToRequest(req, ok)
268
+ if (this._suspended) continue // off means off: acknowledge but drop
269
+ this._onServerFrame(req.data)
270
+ }
271
+ }
272
+
273
+ _onServerFrame(data) {
274
+ const f = parseFrame(data)
275
+ if (!f) {
276
+ return
277
+ }
278
+ if (f.type === TYPE_OPEN) {
279
+ if (this._sessions.has(f.sidHex)) return // duplicate open
280
+ const sid = b4a.from(f.sid) // copy: f.sid views the transient request buffer
281
+ const stream = new GattStream({
282
+ send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
283
+ onclose: () => this._closeServerSession(f.sidHex, sid)
284
+ })
285
+ const session = { stream, sid, conn: null, name: null }
286
+ this._sessions.set(f.sidHex, session)
287
+ session.conn = this._onChannel(stream, false, null)
288
+ // greet the peer with our app-user name so it can label this link
289
+ this._enqueueNotify(frame(TYPE_HELLO, sid, this._helloPayload())).catch(safetyCatch)
290
+ } else if (f.type === TYPE_DATA) {
291
+ const s = this._sessions.get(f.sidHex)
292
+ if (s) s.stream.receive(b4a.from(f.payload))
293
+ } else if (f.type === TYPE_HELLO) {
294
+ const s = this._sessions.get(f.sidHex)
295
+ if (s) this._applyPeerName(s, f.payload)
296
+ } else if (f.type === TYPE_CLOSE) {
297
+ const s = this._sessions.get(f.sidHex)
298
+ if (s) {
299
+ this._sessions.delete(f.sidHex)
300
+ s.stream.remoteEnd()
301
+ }
302
+ } else {
303
+ }
304
+ }
305
+
306
+ _closeServerSession(sidHex, sid) {
307
+ if (!this._sessions.has(sidHex)) return // already torn down (peer sent close)
308
+ this._sessions.delete(sidHex)
309
+ this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch) // best effort
310
+ }
311
+
312
+ // ─── peer display name (hello frame) ──────────────────────────────────────
313
+
314
+ /** Our hello payload: the local app-user name the peer labels this link with. */
315
+ _helloPayload() {
316
+ return b4a.from(JSON.stringify({ n: this.name || '' }))
317
+ }
318
+
319
+ /**
320
+ * Parse a hello payload. Malformed → null (the caller ignores it).
321
+ *
322
+ * @param {Uint8Array} payload
323
+ * @returns {string | null}
324
+ */
325
+ _parseHello(payload) {
326
+ try {
327
+ const { n } = JSON.parse(b4a.toString(payload))
328
+ return typeof n === 'string' ? n : ''
329
+ } catch {
330
+ return null
331
+ }
332
+ }
333
+
334
+ /** Stash a peer's name onto a server session + its conn, then refresh mirrors. */
335
+ _applyPeerName(session, payload) {
336
+ const name = this._parseHello(payload)
337
+ if (name === null) return // malformed hello — ignore
338
+ session.name = name
339
+ if (session.conn) session.conn._peerName = name
340
+ this.emit('update')
341
+ }
342
+
343
+ // Serialize notifications through the single characteristic: updateValue
344
+ // returns false when the peripheral's queue is full — hold the frame and retry
345
+ // on the next 'readyToUpdate', preserving order.
346
+ _enqueueNotify(f) {
347
+ return new Promise((resolve, reject) => {
348
+ this._notifyQueue.push({ frame: f, resolve, reject })
349
+ this._drainNotify()
350
+ })
351
+ }
352
+
353
+ _drainNotify() {
354
+ while (this._notifyQueue.length) {
355
+ const item = this._notifyQueue[0]
356
+ let ok
357
+ try {
358
+ ok = this.server.updateValue(this._dataChar, item.frame)
359
+ } catch (err) {
360
+ this._notifyQueue.shift()
361
+ item.reject(err)
362
+ continue
363
+ }
364
+ if (!ok) return // queue full — wait for 'readyToUpdate', retry same frame
365
+ this._notifyQueue.shift()
366
+ item.resolve()
367
+ }
368
+ }
369
+
370
+ _startScan() {
371
+ if (this._scanning || this.central.state !== 'poweredOn') return
372
+ this._scanning = true
373
+ this.central.startScan([this.serviceUUID], this.scanOptions)
374
+ this._armScanRestart()
375
+ }
376
+
377
+ // Scan scheduler (bitchat model). Lonely: cycle the scan every
378
+ // SCAN_RESTART_LONELY so a re-advertised peer is re-reported (iOS suppresses
379
+ // duplicate discoveries within a scan session). Linked: duty-cycle to save the
380
+ // battery — scan SCAN_DUTY_ON, then go dark SCAN_DUTY_OFF, repeat. The next
381
+ // delay is derived from the current phase, so one timer drives the whole cycle.
382
+ _armScanRestart() {
383
+ if (this._scanTimer) clearTimeout(this._scanTimer)
384
+ const delay =
385
+ this.linkCount > 0 ? (this._scanning ? SCAN_DUTY_ON : SCAN_DUTY_OFF) : SCAN_RESTART_LONELY
386
+ this._scanTimer = setTimeout(() => {
387
+ this._scanTimer = null
388
+ if (this.closing || this.closed || this._suspended) return
389
+ // never toggle the scan mid-dial (would kill the connect) — defer a phase
390
+ if (this._dialing.size > 0) {
391
+ this._armScanRestart()
392
+ return
393
+ }
394
+ if (this.linkCount > 0) {
395
+ if (this._scanning) {
396
+ this._stopScan() // enter the dark half of the duty cycle
397
+ this._armScanRestart()
398
+ } else {
399
+ this._startScan() // wake up and re-arm for the next dark window
400
+ }
401
+ } else {
402
+ this._stopScan() // lonely: cycle so re-advertised peers re-report
403
+ this._startScan()
404
+ }
405
+ }, delay)
406
+ if (this._scanTimer.unref) this._scanTimer.unref()
407
+ }
408
+
409
+ _stopScan() {
410
+ if (!this._scanning) return
411
+ this._scanning = false
412
+ try {
413
+ this.central.stopScan()
414
+ } catch (err) {
415
+ safetyCatch(err)
416
+ }
417
+ }
418
+
419
+ _onState(raw) {
420
+ const next = STATE[raw] ?? 'waiting'
421
+ if (next === this.state) return
422
+ this.state = next
423
+ this.emit('update')
424
+ }
425
+
426
+ _onDiscover(peripheral) {
427
+ if (this.closing || this.closed) return
428
+ if (this._suspended) return
429
+ if (this._linked.has(peripheral.id)) return // already linked
430
+ const knownPeer = this._peerByPeripheral.get(peripheral.id)
431
+ if (knownPeer && this.peers.has(knownPeer)) return // device already linked via another channel
432
+ const cool = this._coolUntil.get(peripheral.id)
433
+ if (cool && cool > Date.now()) return // failed recently — back off
434
+ if (this._dialing.has(peripheral.id)) return // already connecting to this one
435
+ if (this.linkCount >= this.cap) return // gossip covers the rest
436
+ // global rate limit: one radio can't usefully dial faster than this, and
437
+ // back-to-back connects thrash CoreBluetooth (bitchat connect hygiene)
438
+ if (Date.now() - this._lastDial < DIAL_MIN_INTERVAL) return
439
+ // no tie-break: dial every discovery and open a session (bitchat model);
440
+ // a redundant link to the same peer is dropped by _track's dedup
441
+ this._lastDial = Date.now()
442
+ const timer = setTimeout(() => this._abortDial(peripheral, 'timeout'), CONNECT_TIMEOUT)
443
+ this._dialing.set(peripheral.id, timer)
444
+ try {
445
+ this._stopScan()
446
+ this.central.connect(peripheral)
447
+ } catch (err) {
448
+ this._abortDial(peripheral, err)
449
+ }
450
+ }
451
+
452
+ _onConnect(peripheral) {
453
+ this._connectedPeripherals.add(peripheral)
454
+ peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
455
+ peripheral.once('servicesDiscover', (services) => {
456
+ const svc = findByUUID(services, this.serviceUUID)
457
+ if (svc) peripheral.discoverCharacteristics(svc, [DATA_UUID])
458
+ else this._abortDial(peripheral, 'no-service')
459
+ })
460
+ peripheral.once('characteristicsDiscover', (_svc, chars) => {
461
+ const dataChar = findByUUID(chars, DATA_UUID)
462
+ if (dataChar) peripheral.subscribe(dataChar)
463
+ else this._abortDial(peripheral, 'no-data-char')
464
+ })
465
+ peripheral.once('notifyState', (char, isNotifying) => {
466
+ if (!isNotifying) {
467
+ this._abortDial(peripheral, 'subscribe-failed')
468
+ return
469
+ }
470
+ this._startCentralSession(peripheral, char)
471
+ })
472
+ // No tie-break: both sides open a session (bitchat model). On iOS the peer
473
+ // id isn't known until after connect, so a tie-break can only yield
474
+ // post-handshake — which deadlocks when the other side never dials back.
475
+ // Redundant links are tolerated; _track keeps the first and drops the dup.
476
+ peripheral.discoverServices([this.serviceUUID])
477
+ }
478
+
479
+ // ─── central side ─────────────────────────────────────────────────────────
480
+
481
+ _startCentralSession(peripheral, char) {
482
+ const sid = randomBytes(SID_LEN)
483
+ const sidHex = b4a.toString(sid, 'hex')
484
+ peripheral._session = { sidHex, sid }
485
+ peripheral._char = char // suspend's goodbye writes reuse it
486
+ peripheral.on('notify', (_char, data) => this._onCentralNotify(peripheral, data))
487
+ const stream = new GattStream({
488
+ send: (payload) => this._centralSend(peripheral, char, frame(TYPE_DATA, sid, payload)),
489
+ onclose: () => {
490
+ this._centralSend(peripheral, char, frame(TYPE_CLOSE, sid)).catch(safetyCatch)
491
+ this._connectedPeripherals.delete(peripheral)
492
+ try {
493
+ this.central.disconnect(peripheral)
494
+ } catch (err) {
495
+ safetyCatch(err)
496
+ }
497
+ }
498
+ })
499
+ peripheral._stream = stream
500
+ // open frame first: it registers the session on the server before any data
501
+ this._centralSend(peripheral, char, frame(TYPE_OPEN, sid)).catch(safetyCatch)
502
+ // greet the peer with our app-user name so it can label this link
503
+ this._centralSend(peripheral, char, frame(TYPE_HELLO, sid, this._helloPayload())).catch(
504
+ safetyCatch
505
+ )
506
+ this._clearDial(peripheral.id)
507
+ peripheral._conn = this._onChannel(stream, true, peripheral.id)
508
+ }
509
+
510
+ _onCentralNotify(peripheral, data) {
511
+ const sess = peripheral._session
512
+ if (!sess || !peripheral._stream) return
513
+ const f = parseFrame(data)
514
+ if (!f) {
515
+ return
516
+ }
517
+ if (f.sidHex !== sess.sidHex) return // not our session (broadcast to others)
518
+ if (f.type === TYPE_DATA) peripheral._stream.receive(b4a.from(f.payload))
519
+ else if (f.type === TYPE_HELLO) {
520
+ const name = this._parseHello(f.payload)
521
+ if (name === null) return // malformed hello — ignore
522
+ peripheral._peerName = name
523
+ if (peripheral._conn) peripheral._conn._peerName = name
524
+ this.emit('update')
525
+ } else if (f.type === TYPE_CLOSE) {
526
+ peripheral._session = null
527
+ peripheral._stream.remoteEnd()
528
+ }
529
+ }
530
+
531
+ // One write in flight per peripheral: chain each write behind the previous,
532
+ // resolving on the 'write' completion event before the next is issued.
533
+ _centralSend(peripheral, char, f) {
534
+ const prev = peripheral._writeChain || Promise.resolve()
535
+ const next = prev.then(() => this._writeOnce(peripheral, char, f))
536
+ peripheral._writeChain = next.catch(safetyCatch) // keep the chain alive
537
+ return next
538
+ }
539
+
540
+ _writeOnce(peripheral, char, f) {
541
+ return new Promise((resolve, reject) => {
542
+ const cleanup = () => {
543
+ peripheral.removeListener('write', onWrite)
544
+ peripheral.removeListener('error', onErr)
545
+ }
546
+ const onWrite = () => {
547
+ cleanup()
548
+ resolve()
549
+ }
550
+ const onErr = (err) => {
551
+ cleanup()
552
+ reject(err)
553
+ }
554
+ peripheral.once('write', onWrite)
555
+ peripheral.once('error', onErr)
556
+ try {
557
+ peripheral.write(char, f, true)
558
+ } catch (err) {
559
+ cleanup()
560
+ reject(err)
561
+ }
562
+ })
563
+ }
564
+
565
+ _abortDial(peripheral, _reason) {
566
+ const id = peripheral?.id
567
+ // eager when unlinked, patient once linked; per-peer exponential backoff so a
568
+ // peer that keeps failing is retried ever less often (bitchat connect hygiene)
569
+ if (id != null) {
570
+ const count = (this._failures.get(id) || 0) + 1
571
+ this._failures.set(id, count)
572
+ const base = this.linkCount === 0 ? DIAL_COOLDOWN_BASE_LONELY : DIAL_COOLDOWN_BASE
573
+ const cooldown = Math.min(DIAL_COOLDOWN_MAX, base * 2 ** Math.min(4, count - 1))
574
+ this._coolUntil.set(id, Date.now() + cooldown)
575
+ }
576
+ this._clearDial(id)
577
+ this._connectedPeripherals.delete(peripheral)
578
+ try {
579
+ this.central.disconnect(peripheral)
580
+ } catch (err) {
581
+ safetyCatch(err)
582
+ }
583
+ this._startScan()
584
+ }
585
+
586
+ _clearDial(id) {
587
+ if (id == null) return
588
+ const timer = this._dialing.get(id)
589
+ if (timer) clearTimeout(timer)
590
+ this._dialing.delete(id) // free the slot — rediscovery can re-dial
591
+ }
592
+
593
+ // iOS & Android report errored connects as 'error' with a code, not
594
+ // 'disconnect' — without this a failed dial pins the peripheral forever.
595
+ _onCentralError(err) {
596
+ safetyCatch(err)
597
+ const code = err && err.code
598
+ if (code === 'CONNECTION_FAILED' || code === 'DISCONNECT') {
599
+ for (const id of [...this._dialing.keys()]) this._clearDial(id)
600
+ }
601
+ }
602
+
603
+ _onChannel(l2cap, isInitiator, peripheralId) {
604
+ if (this.closing || this.closed || this._suspended) {
605
+ l2cap.destroy()
606
+ return
607
+ }
608
+ const conn = this.network.inject(l2cap, { isInitiator })
609
+ // Liveness: iOS never signals an L2CAP disconnect, so ping the peer and
610
+ // drop the link if we stop hearing back (keepalive refreshes the timeout).
611
+ conn.setKeepAlive(5000)
612
+ conn.setTimeout(15000)
613
+ l2cap.on('error', safetyCatch)
614
+ // marked at channel-open (not handshake-open): rediscovery must not dial a
615
+ // peripheral whose channel is still handshaking
616
+ if (peripheralId != null) {
617
+ this._linked.add(peripheralId)
618
+ this._coolUntil.delete(peripheralId)
619
+ this._failures.delete(peripheralId) // reached a live session — reset backoff
620
+ conn.once('close', () => this._linked.delete(peripheralId))
621
+ }
622
+ conn.on('open', () => this._track(conn, peripheralId, isInitiator))
623
+ conn.on('close', () => this._untrack(conn, peripheralId))
624
+ this._startScan()
625
+ return conn
626
+ }
627
+
628
+ _track(conn, peripheralId, isInitiator) {
629
+ if (peripheralId != null && conn.remotePublicKey) {
630
+ this._peerByPeripheral.set(peripheralId, b4a.toString(conn.remotePublicKey, 'hex'))
631
+ }
632
+ if (this.closing || this.closed) return
633
+ const key = b4a.toString(conn.remotePublicKey, 'hex')
634
+ const existing = this.peers.get(key)
635
+ if (existing && existing !== conn) {
636
+ // Both sides dial (bitchat model) so a pair links twice — two separate
637
+ // channels, each with an initiator end on one device and a responder end
638
+ // on the other. Dropping a channel closes it for BOTH devices, so the two
639
+ // devices MUST retire the same channel or one tears down the peer's kept
640
+ // link. Deterministic winner: keep the channel whose initiator has the
641
+ // smaller static key. Both peers compute it identically (initiator end:
642
+ // our key smaller; responder end: remote key smaller), so the loser
643
+ // channel is dropped on both ends and never cascades onto the survivor.
644
+ const initiatorIsUsSmaller = b4a.compare(conn.publicKey, conn.remotePublicKey) < 0
645
+ const preferred = isInitiator === initiatorIsUsSmaller
646
+ if (!preferred) {
647
+ conn.destroy()
648
+ return
649
+ }
650
+ }
651
+ conn._peripheralId = peripheralId
652
+ conn._peerKey = key
653
+ this.peers.set(key, conn)
654
+ if (existing && existing !== conn) existing.destroy() // retire the loser channel
655
+ this.emit('update')
656
+ }
657
+
658
+ _untrack(conn) {
659
+ const key = conn._peerKey
660
+ if (key && this.peers.get(key) === conn) this.peers.delete(key)
661
+ if (!this.closing && !this.closed) {
662
+ // last link gone → hunt immediately instead of waiting out a dark window
663
+ if (this.linkCount === 0 && !this._suspended) this._startScan()
664
+ this.emit('update')
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Best-effort TYPE_CLOSE to every live session — server sessions over the
670
+ * notify path, central sessions over the write path — reusing the same helpers
671
+ * a normal stream close uses. Waits up to DRAIN_MS for the frames to flush,
672
+ * then resolves regardless: suspend must never hang on a wedged radio.
673
+ *
674
+ * @returns {Promise<void>}
675
+ */
676
+ async _sayGoodbye() {
677
+ const sent = []
678
+ for (const { sid } of this._sessions.values()) {
679
+ sent.push(this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch))
680
+ }
681
+ for (const peripheral of this._connectedPeripherals) {
682
+ const sess = peripheral._session
683
+ if (!sess || !peripheral._char) continue
684
+ const f = frame(TYPE_CLOSE, sess.sid)
685
+ sent.push(this._centralSend(peripheral, peripheral._char, f).catch(safetyCatch))
686
+ }
687
+ if (!sent.length) return
688
+ await Promise.race([Promise.all(sent), new Promise((r) => setTimeout(r, DRAIN_MS))])
689
+ }
690
+
691
+ /**
692
+ * Pause radio activity but KEEP the Server/Central instances and the
693
+ * registered GATT service alive — the toggle-friendly counterpart to _close.
694
+ * iOS CoreBluetooth managers can't be destroy()ed (native double-free), so a
695
+ * fresh transport per toggle leaks a manager whose stale peripheral-manager
696
+ * keeps a duplicate GATT service registered; remote centrals then subscribe to
697
+ * the dead service and hear silence. Reuse one instance instead. Idempotent.
698
+ */
699
+ async suspend() {
700
+ this._suspended = true
701
+ if (this._scanTimer) clearTimeout(this._scanTimer)
702
+ this._scanTimer = null
703
+ for (const timer of this._dialing.values()) clearTimeout(timer)
704
+ this._dialing.clear()
705
+ // Say goodbye BEFORE teardown so the remote reacts in <1s instead of waiting
706
+ // out the 15s keepalive: close frames, a short drain, then drop the physical
707
+ // links (an ACL disconnect is an instant OS-level signal on both roles).
708
+ await this._sayGoodbye()
709
+ for (const peripheral of this._connectedPeripherals) {
710
+ try {
711
+ this.central.disconnect(peripheral)
712
+ } catch (err) {
713
+ safetyCatch(err)
714
+ }
715
+ }
716
+ this._connectedPeripherals.clear()
717
+ try {
718
+ this._stopScan()
719
+ } catch (err) {
720
+ safetyCatch(err)
721
+ }
722
+ try {
723
+ this.server?.stopAdvertising()
724
+ } catch (err) {
725
+ safetyCatch(err)
726
+ }
727
+ this._advertising = false
728
+ for (const conn of this.peers.values()) {
729
+ try {
730
+ conn.destroy()
731
+ } catch (err) {
732
+ safetyCatch(err)
733
+ }
734
+ }
735
+ this.peers.clear()
736
+ for (const { stream } of this._sessions.values()) {
737
+ try {
738
+ stream.destroy()
739
+ } catch (err) {
740
+ safetyCatch(err)
741
+ }
742
+ }
743
+ this._sessions.clear()
744
+ for (const item of this._notifyQueue) item.reject(new Error('suspended'))
745
+ this._notifyQueue = []
746
+ this._linked.clear()
747
+ this._coolUntil.clear()
748
+ this._failures.clear()
749
+ this.state = 'off'
750
+ this.emit('update')
751
+ }
752
+
753
+ /**
754
+ * Restart advertising + scanning on the SAME Server/Central. `_serviceAdded`
755
+ * is still true (the service was never removed) so advertising resumes
756
+ * immediately. Safe to call repeatedly; no-op once closing/closed.
757
+ */
758
+ resume() {
759
+ this._suspended = false
760
+ if (this.closing || this.closed) return
761
+ this._advertising = false
762
+ this._maybeAdvertise()
763
+ this._startScan()
764
+ const raw = this.central?.state ?? this.server?.state
765
+ this.state = STATE[raw] ?? 'waiting'
766
+ this.emit('update')
767
+ }
768
+
769
+ async _close() {
770
+ if (this._scanTimer) clearTimeout(this._scanTimer)
771
+ this._scanTimer = null
772
+ // doctor-app rule: never call central/server.destroy() — it double-frees in
773
+ // the native teardown; stop advertising/scanning and let the runtime reclaim.
774
+ for (const timer of this._dialing.values()) clearTimeout(timer)
775
+ this._dialing.clear()
776
+ try {
777
+ this.central?.stopScan()
778
+ } catch (err) {
779
+ safetyCatch(err)
780
+ }
781
+ try {
782
+ this.server?.stopAdvertising()
783
+ } catch (err) {
784
+ safetyCatch(err)
785
+ }
786
+ if (!this.keepLinks) {
787
+ // channel mesh: toggling nearby off means stop syncing nearby
788
+ for (const conn of this.peers.values()) {
789
+ try {
790
+ conn.destroy()
791
+ } catch (err) {
792
+ safetyCatch(err)
793
+ }
794
+ }
795
+ // kept links keep carrying frames through their sessions/notify queue; only
796
+ // tear this down when we're actually dropping the links
797
+ for (const item of this._notifyQueue) item.reject(new Error('closed'))
798
+ this._notifyQueue = []
799
+ this._sessions.clear()
800
+ }
801
+ this.peers.clear()
802
+ this.state = 'off'
803
+ }
804
+ }