@cero-base/core 1.2.0 → 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.
- package/package.json +1 -1
- package/src/network/bluetooth.js +535 -55
- package/src/network/gatt-stream.js +59 -0
- package/src/network/index.js +9 -1
- package/types/network/bluetooth.d.ts +79 -10
- package/types/network/gatt-stream.d.ts +26 -0
package/package.json
CHANGED
package/src/network/bluetooth.js
CHANGED
|
@@ -1,15 +1,77 @@
|
|
|
1
1
|
import ReadyResource from 'ready-resource'
|
|
2
2
|
import b4a from 'b4a'
|
|
3
|
-
import { hash } from 'hypercore-crypto'
|
|
3
|
+
import { hash, randomBytes } from 'hypercore-crypto'
|
|
4
4
|
import safetyCatch from 'safety-catch'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
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
|
+
|
|
11
22
|
const DEFAULT_CAP = 4
|
|
12
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
|
+
}
|
|
13
75
|
|
|
14
76
|
/**
|
|
15
77
|
* Derive a stable 128-bit BLE service UUID from a topic. Only devices that
|
|
@@ -46,14 +108,15 @@ const uuidEq = (a, b) =>
|
|
|
46
108
|
const findByUUID = (items, uuid) => (items || []).find((i) => uuidEq(i.uuid, uuid)) || null
|
|
47
109
|
|
|
48
110
|
/**
|
|
49
|
-
* Dual-role BLE transport: advertises + scans one service UUID, opens
|
|
50
|
-
*
|
|
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
|
|
51
113
|
* there replication and pairing are transport-agnostic (see Network.inject).
|
|
52
114
|
*
|
|
53
|
-
* Choreography
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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.
|
|
57
120
|
*
|
|
58
121
|
* ponytail: capability-handshake DoS link-scoring is deferred — it needs a
|
|
59
122
|
* replication-progress signal (design §4b). v1 caps links + times out dials.
|
|
@@ -71,6 +134,7 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
71
134
|
* @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
|
|
72
135
|
* @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
|
|
73
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.
|
|
74
138
|
*/
|
|
75
139
|
constructor({
|
|
76
140
|
backend,
|
|
@@ -80,11 +144,13 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
80
144
|
tag = 'cero-ble',
|
|
81
145
|
cap = DEFAULT_CAP,
|
|
82
146
|
scanOptions,
|
|
83
|
-
keepLinks = false
|
|
147
|
+
keepLinks = false,
|
|
148
|
+
name
|
|
84
149
|
}) {
|
|
85
150
|
super()
|
|
86
151
|
this.backend = backend
|
|
87
152
|
this.network = network
|
|
153
|
+
this.name = name || ''
|
|
88
154
|
this.nodeId = nodeId
|
|
89
155
|
this.nodeHex = b4a.toString(nodeId, 'hex')
|
|
90
156
|
this.serviceUUID = toServiceUUID(uuid, tag)
|
|
@@ -95,12 +161,31 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
95
161
|
this.state = 'off'
|
|
96
162
|
this.central = null
|
|
97
163
|
this.server = null
|
|
98
|
-
this.
|
|
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 = []
|
|
99
169
|
this._scanning = false
|
|
100
170
|
this._advertising = false
|
|
101
171
|
this._serviceAdded = false
|
|
102
172
|
/** peripheral id being dialed → its connect-timeout timer */
|
|
103
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
|
|
104
189
|
/** live injected links keyed by remote node id hex */
|
|
105
190
|
this.peers = new Map()
|
|
106
191
|
}
|
|
@@ -133,18 +218,15 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
133
218
|
this._serviceAdded = true
|
|
134
219
|
this._maybeAdvertise()
|
|
135
220
|
})
|
|
136
|
-
this.server.on('
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
this.server.respondToRequest(req, ok, val)
|
|
146
|
-
})
|
|
147
|
-
this.server.on('channelOpen', (l2cap) => this._onChannel(l2cap, false, null))
|
|
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', () => {})
|
|
148
230
|
this.server.on('error', safetyCatch)
|
|
149
231
|
|
|
150
232
|
this.central = new Central()
|
|
@@ -165,22 +247,173 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
165
247
|
_startServer(Service, Characteristic) {
|
|
166
248
|
if (this.server.state !== 'poweredOn') return // wait for the adapter (stateChange)
|
|
167
249
|
if (!this._serviceAdded) {
|
|
168
|
-
|
|
169
|
-
this.server.addService(new Service(this.serviceUUID, [
|
|
250
|
+
this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
|
|
251
|
+
this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
|
|
170
252
|
}
|
|
171
|
-
if (this.psm == null) this.server.publishChannel({})
|
|
172
253
|
}
|
|
173
254
|
|
|
174
255
|
_maybeAdvertise() {
|
|
175
|
-
if (this._advertising || !this._serviceAdded
|
|
256
|
+
if (this._advertising || !this._serviceAdded) return
|
|
176
257
|
this._advertising = true
|
|
177
258
|
this.server.startAdvertising({ serviceUUIDs: [this.serviceUUID] })
|
|
178
259
|
}
|
|
179
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
|
+
|
|
180
370
|
_startScan() {
|
|
181
371
|
if (this._scanning || this.central.state !== 'poweredOn') return
|
|
182
372
|
this._scanning = true
|
|
183
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
|
+
}
|
|
184
417
|
}
|
|
185
418
|
|
|
186
419
|
_onState(raw) {
|
|
@@ -192,13 +425,24 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
192
425
|
|
|
193
426
|
_onDiscover(peripheral) {
|
|
194
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
|
|
195
434
|
if (this._dialing.has(peripheral.id)) return // already connecting to this one
|
|
196
435
|
if (this.linkCount >= this.cap) return // gossip covers the rest
|
|
197
|
-
//
|
|
198
|
-
//
|
|
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()
|
|
199
442
|
const timer = setTimeout(() => this._abortDial(peripheral, 'timeout'), CONNECT_TIMEOUT)
|
|
200
443
|
this._dialing.set(peripheral.id, timer)
|
|
201
444
|
try {
|
|
445
|
+
this._stopScan()
|
|
202
446
|
this.central.connect(peripheral)
|
|
203
447
|
} catch (err) {
|
|
204
448
|
this._abortDial(peripheral, err)
|
|
@@ -206,44 +450,137 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
206
450
|
}
|
|
207
451
|
|
|
208
452
|
_onConnect(peripheral) {
|
|
453
|
+
this._connectedPeripherals.add(peripheral)
|
|
209
454
|
peripheral.on('error', () => this._abortDial(peripheral, 'peripheral-error'))
|
|
210
455
|
peripheral.once('servicesDiscover', (services) => {
|
|
211
456
|
const svc = findByUUID(services, this.serviceUUID)
|
|
212
|
-
if (svc) peripheral.discoverCharacteristics(svc, [
|
|
457
|
+
if (svc) peripheral.discoverCharacteristics(svc, [DATA_UUID])
|
|
213
458
|
else this._abortDial(peripheral, 'no-service')
|
|
214
459
|
})
|
|
215
460
|
peripheral.once('characteristicsDiscover', (_svc, chars) => {
|
|
216
|
-
const
|
|
217
|
-
if (
|
|
218
|
-
else this._abortDial(peripheral, 'no-
|
|
461
|
+
const dataChar = findByUUID(chars, DATA_UUID)
|
|
462
|
+
if (dataChar) peripheral.subscribe(dataChar)
|
|
463
|
+
else this._abortDial(peripheral, 'no-data-char')
|
|
219
464
|
})
|
|
220
|
-
peripheral.once('
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const peerId = peerHex ? b4a.from(peerHex, 'hex') : null
|
|
224
|
-
// the smaller id opens the channel; the larger disconnects and waits for
|
|
225
|
-
// the peer to dial back — a pair links once, not twice
|
|
226
|
-
if (!psm || !peerId || !this.shouldInitiate(peerId)) {
|
|
227
|
-
this._abortDial(peripheral, 'yield')
|
|
465
|
+
peripheral.once('notifyState', (char, isNotifying) => {
|
|
466
|
+
if (!isNotifying) {
|
|
467
|
+
this._abortDial(peripheral, 'subscribe-failed')
|
|
228
468
|
return
|
|
229
469
|
}
|
|
230
|
-
|
|
231
|
-
this._clearDial(peripheral.id)
|
|
232
|
-
this._onChannel(l2cap, true, peripheral.id)
|
|
233
|
-
})
|
|
234
|
-
peripheral.openL2CAPChannel(psm)
|
|
470
|
+
this._startCentralSession(peripheral, char)
|
|
235
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.
|
|
236
476
|
peripheral.discoverServices([this.serviceUUID])
|
|
237
477
|
}
|
|
238
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
|
+
|
|
239
565
|
_abortDial(peripheral, _reason) {
|
|
240
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
|
+
}
|
|
241
576
|
this._clearDial(id)
|
|
577
|
+
this._connectedPeripherals.delete(peripheral)
|
|
242
578
|
try {
|
|
243
579
|
this.central.disconnect(peripheral)
|
|
244
580
|
} catch (err) {
|
|
245
581
|
safetyCatch(err)
|
|
246
582
|
}
|
|
583
|
+
this._startScan()
|
|
247
584
|
}
|
|
248
585
|
|
|
249
586
|
_clearDial(id) {
|
|
@@ -264,36 +601,174 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
264
601
|
}
|
|
265
602
|
|
|
266
603
|
_onChannel(l2cap, isInitiator, peripheralId) {
|
|
267
|
-
if (this.closing || this.closed) {
|
|
604
|
+
if (this.closing || this.closed || this._suspended) {
|
|
268
605
|
l2cap.destroy()
|
|
269
606
|
return
|
|
270
607
|
}
|
|
271
608
|
const conn = this.network.inject(l2cap, { isInitiator })
|
|
272
|
-
|
|
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))
|
|
273
623
|
conn.on('close', () => this._untrack(conn, peripheralId))
|
|
624
|
+
this._startScan()
|
|
625
|
+
return conn
|
|
274
626
|
}
|
|
275
627
|
|
|
276
|
-
_track(conn, peripheralId) {
|
|
628
|
+
_track(conn, peripheralId, isInitiator) {
|
|
629
|
+
if (peripheralId != null && conn.remotePublicKey) {
|
|
630
|
+
this._peerByPeripheral.set(peripheralId, b4a.toString(conn.remotePublicKey, 'hex'))
|
|
631
|
+
}
|
|
277
632
|
if (this.closing || this.closed) return
|
|
278
633
|
const key = b4a.toString(conn.remotePublicKey, 'hex')
|
|
279
634
|
const existing = this.peers.get(key)
|
|
280
635
|
if (existing && existing !== conn) {
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
}
|
|
283
650
|
}
|
|
284
651
|
conn._peripheralId = peripheralId
|
|
285
652
|
conn._peerKey = key
|
|
286
653
|
this.peers.set(key, conn)
|
|
654
|
+
if (existing && existing !== conn) existing.destroy() // retire the loser channel
|
|
287
655
|
this.emit('update')
|
|
288
656
|
}
|
|
289
657
|
|
|
290
658
|
_untrack(conn) {
|
|
291
659
|
const key = conn._peerKey
|
|
292
660
|
if (key && this.peers.get(key) === conn) this.peers.delete(key)
|
|
293
|
-
if (!this.closing && !this.closed)
|
|
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')
|
|
294
767
|
}
|
|
295
768
|
|
|
296
769
|
async _close() {
|
|
770
|
+
if (this._scanTimer) clearTimeout(this._scanTimer)
|
|
771
|
+
this._scanTimer = null
|
|
297
772
|
// doctor-app rule: never call central/server.destroy() — it double-frees in
|
|
298
773
|
// the native teardown; stop advertising/scanning and let the runtime reclaim.
|
|
299
774
|
for (const timer of this._dialing.values()) clearTimeout(timer)
|
|
@@ -317,6 +792,11 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
317
792
|
safetyCatch(err)
|
|
318
793
|
}
|
|
319
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()
|
|
320
800
|
}
|
|
321
801
|
this.peers.clear()
|
|
322
802
|
this.state = 'off'
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Duplex } from 'streamx'
|
|
2
|
+
|
|
3
|
+
// iOS caps a single GATT write (and a notify payload) at ATT_MTU − 3 ≈ 182
|
|
4
|
+
// bytes. 150 stays safely under that without negotiating an MTU. Raise later
|
|
5
|
+
// via the peripheral's maximumWriteValueLength + write-without-response.
|
|
6
|
+
const PAYLOAD = 150
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A dumb byte-carrying duplex for the GATT transport. Framing and session logic
|
|
10
|
+
* live in BluetoothTransport; this only fragments outbound writes to fit a GATT
|
|
11
|
+
* write and pushes inbound payload bytes. NoiseSecretStream wraps it as a raw
|
|
12
|
+
* duplex, exactly like the old L2CAP channel.
|
|
13
|
+
*
|
|
14
|
+
* @extends Duplex
|
|
15
|
+
*/
|
|
16
|
+
export class GattStream extends Duplex {
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} opts
|
|
19
|
+
* @param {(buffer: Uint8Array) => Promise<void>} opts.send Transmit one payload piece (transport frames it).
|
|
20
|
+
* @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
|
|
21
|
+
*/
|
|
22
|
+
constructor({ send, onclose } = {}) {
|
|
23
|
+
super()
|
|
24
|
+
this._send = send
|
|
25
|
+
this._onclose = onclose || null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async _write(chunk, cb) {
|
|
29
|
+
try {
|
|
30
|
+
for (let offset = 0; offset < chunk.byteLength; offset += PAYLOAD) {
|
|
31
|
+
await this._send(chunk.subarray(offset, offset + PAYLOAD))
|
|
32
|
+
}
|
|
33
|
+
cb(null)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
cb(err)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
receive(buffer) {
|
|
40
|
+
this.push(buffer)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
remoteEnd() {
|
|
44
|
+
this.push(null)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_destroy(cb) {
|
|
48
|
+
const onclose = this._onclose
|
|
49
|
+
this._onclose = null
|
|
50
|
+
if (onclose) {
|
|
51
|
+
try {
|
|
52
|
+
onclose()
|
|
53
|
+
} catch {
|
|
54
|
+
// teardown is best-effort
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
cb(null)
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/network/index.js
CHANGED
|
@@ -60,8 +60,16 @@ export class Network extends ReadyResource {
|
|
|
60
60
|
inject(stream, { isInitiator } = {}) {
|
|
61
61
|
if (this.closing || this.closed) throw CeroError.CLOSED('Network')
|
|
62
62
|
if (!stream) throw CeroError.REQUIRED('stream')
|
|
63
|
+
// Injected links authenticate with the same long-lived identity as the
|
|
64
|
+
// swarm — an ephemeral key per link would defeat duplicate-peer detection
|
|
65
|
+
// (two radios between one pair would look like two different peers).
|
|
66
|
+
const keyPair = this.identity
|
|
67
|
+
? { publicKey: this.identity.publicKey, secretKey: this.identity.secretKey }
|
|
68
|
+
: (this.swarm?.keyPair ?? undefined)
|
|
63
69
|
const conn =
|
|
64
|
-
stream.noiseStream === stream
|
|
70
|
+
stream.noiseStream === stream
|
|
71
|
+
? stream
|
|
72
|
+
: new NoiseSecretStream(isInitiator === true, stream, keyPair ? { keyPair } : undefined)
|
|
65
73
|
|
|
66
74
|
// blind-pairing picks the lowest-`rtt` unvisited channel to send on; that
|
|
67
75
|
// field only exists on real udx sockets. A raw injected duplex has none,
|
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export function toServiceUUID(topic: Uint8Array, tag?: string): string;
|
|
10
10
|
/**
|
|
11
|
-
* Dual-role BLE transport: advertises + scans one service UUID, opens
|
|
12
|
-
*
|
|
11
|
+
* Dual-role BLE transport: advertises + scans one service UUID, opens a GATT
|
|
12
|
+
* byte-stream to each discovered peer, and feeds it into `network.inject`. From
|
|
13
13
|
* there replication and pairing are transport-agnostic (see Network.inject).
|
|
14
14
|
*
|
|
15
|
-
* Choreography
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* Choreography: the server adds one data characteristic (write + notify) and
|
|
16
|
+
* advertises. The central connects, discovers the characteristic, subscribes,
|
|
17
|
+
* then framed bytes flow both ways — central→server as GATT writes, server→
|
|
18
|
+
* central as notifications — each tagged with an 8-byte session id (bitchat
|
|
19
|
+
* model). `backend` is bare-bluetooth in production and a mock in tests.
|
|
19
20
|
*
|
|
20
21
|
* ponytail: capability-handshake DoS link-scoring is deferred — it needs a
|
|
21
22
|
* replication-progress signal (design §4b). v1 caps links + times out dials.
|
|
@@ -33,8 +34,9 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
33
34
|
* @param {number} [opts.cap] Max concurrent links; gossip covers the rest.
|
|
34
35
|
* @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
|
|
35
36
|
* @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).
|
|
37
|
+
* @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
|
|
36
38
|
*/
|
|
37
|
-
constructor({ backend, network, uuid, nodeId, tag, cap, scanOptions, keepLinks }: {
|
|
39
|
+
constructor({ backend, network, uuid, nodeId, tag, cap, scanOptions, keepLinks, name }: {
|
|
38
40
|
backend: any;
|
|
39
41
|
network: import("./index.js").Network;
|
|
40
42
|
uuid: Uint8Array;
|
|
@@ -45,9 +47,11 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
45
47
|
scanMode?: any;
|
|
46
48
|
};
|
|
47
49
|
keepLinks?: boolean;
|
|
50
|
+
name?: string;
|
|
48
51
|
});
|
|
49
52
|
backend: any;
|
|
50
53
|
network: import("./index.js").Network;
|
|
54
|
+
name: string;
|
|
51
55
|
nodeId: Uint8Array<ArrayBufferLike>;
|
|
52
56
|
nodeHex: any;
|
|
53
57
|
serviceUUID: string;
|
|
@@ -59,12 +63,31 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
59
63
|
state: string;
|
|
60
64
|
central: any;
|
|
61
65
|
server: any;
|
|
62
|
-
|
|
66
|
+
_dataChar: any;
|
|
67
|
+
/** sessionId hex → { stream, sid } for server-side (peripheral) sessions */
|
|
68
|
+
_sessions: Map<any, any>;
|
|
69
|
+
/** serialized server notify queue: { frame, resolve, reject } */
|
|
70
|
+
_notifyQueue: any[];
|
|
63
71
|
_scanning: boolean;
|
|
64
72
|
_advertising: boolean;
|
|
65
73
|
_serviceAdded: boolean;
|
|
66
74
|
/** peripheral id being dialed → its connect-timeout timer */
|
|
67
75
|
_dialing: Map<any, any>;
|
|
76
|
+
/** peripheral ids that carry a live channel — never re-dialed (a second
|
|
77
|
+
* dial's failure would disconnect the peripheral and kill the good link) */
|
|
78
|
+
_linked: Set<any>;
|
|
79
|
+
/** peripheral id → retry-after timestamp; failed dials back off */
|
|
80
|
+
_coolUntil: Map<any, any>;
|
|
81
|
+
/** peripheral id → consecutive failure count; drives exponential backoff */
|
|
82
|
+
_failures: Map<any, any>;
|
|
83
|
+
/** peripheral id → remote peer key, learned at handshake — dial guard */
|
|
84
|
+
_peerByPeripheral: Map<any, any>;
|
|
85
|
+
/** live central-side peripheral wrappers — for goodbye + physical hang-up on suspend */
|
|
86
|
+
_connectedPeripherals: Set<any>;
|
|
87
|
+
/** last central.connect timestamp — global inter-dial rate limit */
|
|
88
|
+
_lastDial: number;
|
|
89
|
+
_scanTimer: any;
|
|
90
|
+
_suspended: boolean;
|
|
68
91
|
/** live injected links keyed by remote node id hex */
|
|
69
92
|
peers: Map<any, any>;
|
|
70
93
|
/**
|
|
@@ -79,15 +102,61 @@ export class BluetoothTransport extends ReadyResource {
|
|
|
79
102
|
get linkCount(): number;
|
|
80
103
|
_startServer(Service: any, Characteristic: any): void;
|
|
81
104
|
_maybeAdvertise(): void;
|
|
105
|
+
_onWriteRequests(requests: any): void;
|
|
106
|
+
_onServerFrame(data: any): void;
|
|
107
|
+
_closeServerSession(sidHex: any, sid: any): void;
|
|
108
|
+
/** Our hello payload: the local app-user name the peer labels this link with. */
|
|
109
|
+
_helloPayload(): any;
|
|
110
|
+
/**
|
|
111
|
+
* Parse a hello payload. Malformed → null (the caller ignores it).
|
|
112
|
+
*
|
|
113
|
+
* @param {Uint8Array} payload
|
|
114
|
+
* @returns {string | null}
|
|
115
|
+
*/
|
|
116
|
+
_parseHello(payload: Uint8Array): string | null;
|
|
117
|
+
/** Stash a peer's name onto a server session + its conn, then refresh mirrors. */
|
|
118
|
+
_applyPeerName(session: any, payload: any): void;
|
|
119
|
+
_enqueueNotify(f: any): Promise<any>;
|
|
120
|
+
_drainNotify(): void;
|
|
82
121
|
_startScan(): void;
|
|
122
|
+
_armScanRestart(): void;
|
|
123
|
+
_stopScan(): void;
|
|
83
124
|
_onState(raw: any): void;
|
|
84
125
|
_onDiscover(peripheral: any): void;
|
|
85
126
|
_onConnect(peripheral: any): void;
|
|
127
|
+
_startCentralSession(peripheral: any, char: any): void;
|
|
128
|
+
_onCentralNotify(peripheral: any, data: any): void;
|
|
129
|
+
_centralSend(peripheral: any, char: any, f: any): any;
|
|
130
|
+
_writeOnce(peripheral: any, char: any, f: any): Promise<any>;
|
|
86
131
|
_abortDial(peripheral: any, _reason: any): void;
|
|
87
132
|
_clearDial(id: any): void;
|
|
88
133
|
_onCentralError(err: any): void;
|
|
89
|
-
_onChannel(l2cap: any, isInitiator: any, peripheralId: any):
|
|
90
|
-
_track(conn: any, peripheralId: any): void;
|
|
134
|
+
_onChannel(l2cap: any, isInitiator: any, peripheralId: any): any;
|
|
135
|
+
_track(conn: any, peripheralId: any, isInitiator: any): void;
|
|
91
136
|
_untrack(conn: any): void;
|
|
137
|
+
/**
|
|
138
|
+
* Best-effort TYPE_CLOSE to every live session — server sessions over the
|
|
139
|
+
* notify path, central sessions over the write path — reusing the same helpers
|
|
140
|
+
* a normal stream close uses. Waits up to DRAIN_MS for the frames to flush,
|
|
141
|
+
* then resolves regardless: suspend must never hang on a wedged radio.
|
|
142
|
+
*
|
|
143
|
+
* @returns {Promise<void>}
|
|
144
|
+
*/
|
|
145
|
+
_sayGoodbye(): Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Pause radio activity but KEEP the Server/Central instances and the
|
|
148
|
+
* registered GATT service alive — the toggle-friendly counterpart to _close.
|
|
149
|
+
* iOS CoreBluetooth managers can't be destroy()ed (native double-free), so a
|
|
150
|
+
* fresh transport per toggle leaks a manager whose stale peripheral-manager
|
|
151
|
+
* keeps a duplicate GATT service registered; remote centrals then subscribe to
|
|
152
|
+
* the dead service and hear silence. Reuse one instance instead. Idempotent.
|
|
153
|
+
*/
|
|
154
|
+
suspend(): Promise<void>;
|
|
155
|
+
/**
|
|
156
|
+
* Restart advertising + scanning on the SAME Server/Central. `_serviceAdded`
|
|
157
|
+
* is still true (the service was never removed) so advertising resumes
|
|
158
|
+
* immediately. Safe to call repeatedly; no-op once closing/closed.
|
|
159
|
+
*/
|
|
160
|
+
resume(): void;
|
|
92
161
|
}
|
|
93
162
|
import ReadyResource from 'ready-resource';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dumb byte-carrying duplex for the GATT transport. Framing and session logic
|
|
3
|
+
* live in BluetoothTransport; this only fragments outbound writes to fit a GATT
|
|
4
|
+
* write and pushes inbound payload bytes. NoiseSecretStream wraps it as a raw
|
|
5
|
+
* duplex, exactly like the old L2CAP channel.
|
|
6
|
+
*
|
|
7
|
+
* @extends Duplex
|
|
8
|
+
*/
|
|
9
|
+
export class GattStream extends Duplex<import("streamx").DuplexEvents> {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {(buffer: Uint8Array) => Promise<void>} opts.send Transmit one payload piece (transport frames it).
|
|
13
|
+
* @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
|
|
14
|
+
*/
|
|
15
|
+
constructor({ send, onclose }?: {
|
|
16
|
+
send: (buffer: Uint8Array) => Promise<void>;
|
|
17
|
+
onclose?: () => void;
|
|
18
|
+
});
|
|
19
|
+
_send: (buffer: Uint8Array) => Promise<void>;
|
|
20
|
+
_onclose: () => void;
|
|
21
|
+
_write(chunk: any, cb: any): Promise<void>;
|
|
22
|
+
receive(buffer: any): void;
|
|
23
|
+
remoteEnd(): void;
|
|
24
|
+
_destroy(cb: any): void;
|
|
25
|
+
}
|
|
26
|
+
import { Duplex } from 'streamx';
|