@cero-base/core 1.8.1 → 1.10.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 +6 -6
- package/src/database/encryption.js +5 -0
- package/src/network/transports/ble.js +329 -48
- package/src/network/transports/l2cap.js +100 -0
- package/src/pairing/index.js +67 -3
- package/types/database/encryption.d.ts +1 -0
- package/types/network/transports/ble.d.ts +37 -2
- package/types/network/transports/l2cap.d.ts +38 -0
- package/types/pairing/index.d.ts +31 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -144,15 +144,15 @@
|
|
|
144
144
|
},
|
|
145
145
|
"dependencies": {
|
|
146
146
|
"@hyperswarm/secret-stream": "^6.9.1",
|
|
147
|
-
"autobee": "
|
|
147
|
+
"autobee": "2.0.0-rc.6",
|
|
148
148
|
"autobee-encryption": "0.1.3",
|
|
149
149
|
"b4a": "^1.8.1",
|
|
150
150
|
"bare-crypto": "^1.15.3",
|
|
151
|
-
"bare-fs": "^4.
|
|
151
|
+
"bare-fs": "^4.8.0",
|
|
152
152
|
"bare-path": "^3.1.1",
|
|
153
153
|
"bip39-mnemonic": "^2.5.0",
|
|
154
154
|
"blind-pairing": "^2.3.1",
|
|
155
|
-
"blind-peering": "^2.
|
|
155
|
+
"blind-peering": "^2.6.1",
|
|
156
156
|
"compact-encoding": "^3.3.0",
|
|
157
157
|
"corestore": "^7.12.0",
|
|
158
158
|
"framed-stream": "^1.0.1",
|
|
@@ -181,8 +181,8 @@
|
|
|
181
181
|
"bare-events": "^2.9.1",
|
|
182
182
|
"bare-fetch": "^3.2.0",
|
|
183
183
|
"bare-process": "^4.5.1",
|
|
184
|
-
"bare-url": "^2.
|
|
185
|
-
"blind-peer": "^3.
|
|
184
|
+
"bare-url": "^2.5.2",
|
|
185
|
+
"blind-peer": "^3.13.2",
|
|
186
186
|
"brittle": "^4.1.0",
|
|
187
187
|
"typescript": "^5.9.3",
|
|
188
188
|
"which-runtime": "^1.4.0"
|
|
@@ -238,6 +238,11 @@ export class EpochAutobee extends Autobee {
|
|
|
238
238
|
// step so an UNKNOWN_EPOCH surfacing from batch processing parks too
|
|
239
239
|
// instead of crashing the bee.
|
|
240
240
|
async _bumpPendingWriters() {
|
|
241
|
+
if (this._catchupMigratedNodes !== null) {
|
|
242
|
+
await this._bumpMigratedWriters()
|
|
243
|
+
this._catchupMigratedNodes = null
|
|
244
|
+
}
|
|
245
|
+
|
|
241
246
|
let updated = false
|
|
242
247
|
|
|
243
248
|
const pending = this.writers.pending.slice()
|
|
@@ -4,12 +4,20 @@ import { hash, randomBytes } from 'hypercore-crypto'
|
|
|
4
4
|
import safetyCatch from 'safety-catch'
|
|
5
5
|
|
|
6
6
|
import { GattStream } from './gatt.js'
|
|
7
|
+
import { L2CAPStream, readSidPreamble } from './l2cap.js'
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// framed byte stream both ways instead.
|
|
9
|
+
// One data characteristic (write + notify) carries the framed control traffic
|
|
10
|
+
// both ways — and, on the gatt pipe, the data too.
|
|
11
11
|
const DATA_UUID = 'ce1a0004-0000-1000-8000-00805f9b34fb'
|
|
12
12
|
|
|
13
|
+
// The l2cap open historically hangs forever on some platform pairs (no error,
|
|
14
|
+
// no disconnect) — every open runs under this deadline instead.
|
|
15
|
+
const L2CAP_OPEN_TIMEOUT = 3000
|
|
16
|
+
// a pending server session must see its channel within this window or be reaped
|
|
17
|
+
const PIPE_PENDING_TIMEOUT = 20000
|
|
18
|
+
|
|
19
|
+
const PLATFORM = typeof Bare !== 'undefined' ? Bare.platform : process.platform
|
|
20
|
+
|
|
13
21
|
// Wire frame both directions: [type:1][sessionId:8][payload].
|
|
14
22
|
const TYPE_OPEN = 1
|
|
15
23
|
const TYPE_DATA = 2
|
|
@@ -132,6 +140,8 @@ export class BLETransport extends ReadyResource {
|
|
|
132
140
|
* @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
|
|
133
141
|
* @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).
|
|
134
142
|
* @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
|
|
143
|
+
* @param {'l2cap' | 'gatt'} [opts.pipe] Data pipe: 'l2cap' (default — a real channel per session, several times faster) or 'gatt' (framed characteristic stream). Both peers must match.
|
|
144
|
+
* @param {{ timeout?: number }} [opts.l2cap] Deadline for an l2cap channel open.
|
|
135
145
|
*/
|
|
136
146
|
constructor({
|
|
137
147
|
backend,
|
|
@@ -143,7 +153,9 @@ export class BLETransport extends ReadyResource {
|
|
|
143
153
|
maxInbound = DEFAULT_MAX_INBOUND,
|
|
144
154
|
scanOptions,
|
|
145
155
|
keepLinks = false,
|
|
146
|
-
name
|
|
156
|
+
name,
|
|
157
|
+
pipe = 'l2cap',
|
|
158
|
+
l2cap = {}
|
|
147
159
|
}) {
|
|
148
160
|
super()
|
|
149
161
|
this.backend = backend
|
|
@@ -156,11 +168,15 @@ export class BLETransport extends ReadyResource {
|
|
|
156
168
|
this.maxInbound = maxInbound
|
|
157
169
|
this.scanOptions = scanOptions
|
|
158
170
|
this.keepLinks = keepLinks
|
|
171
|
+
this.pipe = pipe
|
|
172
|
+
this._l2capTimeout = l2cap.timeout ?? L2CAP_OPEN_TIMEOUT
|
|
159
173
|
|
|
160
174
|
this.state = 'off'
|
|
161
175
|
this.central = null
|
|
162
176
|
this.server = null
|
|
163
177
|
this._dataChar = null
|
|
178
|
+
/** the published l2cap listener's psm, advertised to centrals over hello */
|
|
179
|
+
this._psm = null
|
|
164
180
|
/** sessionId hex → { stream, sid } for server-side (peripheral) sessions */
|
|
165
181
|
this._sessions = new Map()
|
|
166
182
|
/** serialized server notify queue: { frame, resolve, reject } */
|
|
@@ -217,6 +233,7 @@ export class BLETransport extends ReadyResource {
|
|
|
217
233
|
this.server.on('stateChange', (s) => {
|
|
218
234
|
this._onState(s)
|
|
219
235
|
if (s === 'poweredOn') this._startServer(Service, Characteristic)
|
|
236
|
+
else if (s === 'poweredOff' || s === 'resetting') this._onRadioDown()
|
|
220
237
|
})
|
|
221
238
|
this.server.on('serviceAdd', () => {
|
|
222
239
|
this._serviceAdded = true
|
|
@@ -224,6 +241,10 @@ export class BLETransport extends ReadyResource {
|
|
|
224
241
|
})
|
|
225
242
|
this.server.on('writeRequest', (reqs) => this._onWriteRequests(reqs))
|
|
226
243
|
this.server.on('readyToUpdate', () => this._drainNotify())
|
|
244
|
+
this.server.on('channelPublish', (psm) => {
|
|
245
|
+
this._psm = psm
|
|
246
|
+
})
|
|
247
|
+
this.server.on('channelOpen', (channel) => this._onServerChannel(channel))
|
|
227
248
|
// writeRequests carry no central identifier, so an unsubscribe can't be
|
|
228
249
|
// mapped to a session; teardown is left to _onChannel's keepalive/timeout.
|
|
229
250
|
this.server.on('unsubscribe', () => {})
|
|
@@ -233,6 +254,7 @@ export class BLETransport extends ReadyResource {
|
|
|
233
254
|
this.central.on('stateChange', (s) => {
|
|
234
255
|
this._onState(s)
|
|
235
256
|
if (s === 'poweredOn') this._startScan()
|
|
257
|
+
else if (s === 'poweredOff' || s === 'resetting') this._onRadioDown()
|
|
236
258
|
})
|
|
237
259
|
this.central.on('discover', (peripheral) => this._onDiscover(peripheral))
|
|
238
260
|
this.central.on('connect', (peripheral) => this._onConnect(peripheral))
|
|
@@ -250,6 +272,39 @@ export class BLETransport extends ReadyResource {
|
|
|
250
272
|
this._dataChar = new Characteristic(DATA_UUID, { write: true, notify: true })
|
|
251
273
|
this.server.addService(new Service(this.serviceUUID, [this._dataChar]))
|
|
252
274
|
}
|
|
275
|
+
if (this.pipe === 'l2cap') this._publishListener()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
_publishListener() {
|
|
279
|
+
if (this._psm !== null || typeof this.server.publishChannel !== 'function') return
|
|
280
|
+
try {
|
|
281
|
+
// unencrypted: cero's own protocols provide the crypto; encryption here
|
|
282
|
+
// would demand BLE pairing and stall centrals that never trigger it
|
|
283
|
+
this.server.publishChannel({})
|
|
284
|
+
} catch (err) {
|
|
285
|
+
safetyCatch(err)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
_unpublishListener() {
|
|
290
|
+
if (this._psm === null) return
|
|
291
|
+
if (typeof this.server?.unpublishChannel === 'function') {
|
|
292
|
+
try {
|
|
293
|
+
this.server.unpublishChannel(this._psm)
|
|
294
|
+
} catch (err) {
|
|
295
|
+
safetyCatch(err)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
this._psm = null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Fresh listener, fresh psm — the next hello advertises it. A dead session
|
|
302
|
+
// leaves its channel state on the shared radio link, and the OS refuses a
|
|
303
|
+
// second open to a psm it remembers there.
|
|
304
|
+
_cycleListener() {
|
|
305
|
+
if (this.pipe !== 'l2cap' || this._suspended || this.closing || this.closed) return
|
|
306
|
+
this._unpublishListener()
|
|
307
|
+
this._publishListener()
|
|
253
308
|
}
|
|
254
309
|
|
|
255
310
|
_maybeAdvertise() {
|
|
@@ -282,55 +337,136 @@ export class BLETransport extends ReadyResource {
|
|
|
282
337
|
return
|
|
283
338
|
}
|
|
284
339
|
const sid = b4a.from(f.sid) // copy: f.sid views the transient request buffer
|
|
285
|
-
const
|
|
286
|
-
send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
|
|
287
|
-
onclose: () => this._closeServerSession(f.sidHex, sid)
|
|
288
|
-
})
|
|
289
|
-
const session = { stream, sid, conn: null, name: null }
|
|
340
|
+
const session = { stream: null, sid, conn: null, name: null, pipeTimer: null }
|
|
290
341
|
this._sessions.set(f.sidHex, session)
|
|
291
|
-
|
|
342
|
+
if (this.pipe === 'l2cap' && this._psm !== null) {
|
|
343
|
+
// the session has no stream until the central opens our channel and
|
|
344
|
+
// its sid preamble matches — reap it if that never happens
|
|
345
|
+
session.pipeTimer = setTimeout(() => {
|
|
346
|
+
session.pipeTimer = null
|
|
347
|
+
if (this._sessions.get(f.sidHex) !== session || session.stream) return
|
|
348
|
+
this._reapSession(f.sidHex, session)
|
|
349
|
+
this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
|
|
350
|
+
}, PIPE_PENDING_TIMEOUT)
|
|
351
|
+
if (session.pipeTimer.unref) session.pipeTimer.unref()
|
|
352
|
+
} else {
|
|
353
|
+
this._openServerGatt(f.sidHex, session)
|
|
354
|
+
}
|
|
292
355
|
this._enqueueNotify(frame(TYPE_HELLO, sid, this._helloPayload())).catch(safetyCatch)
|
|
293
356
|
} else if (f.type === TYPE_DATA) {
|
|
294
357
|
const s = this._sessions.get(f.sidHex)
|
|
295
|
-
if (s)
|
|
358
|
+
if (!s) return
|
|
359
|
+
if (!s.stream) {
|
|
360
|
+
// gatt data on a session awaiting its l2cap channel is a pipe
|
|
361
|
+
// mismatch — close instead of silently degrading
|
|
362
|
+
this._reapSession(f.sidHex, s)
|
|
363
|
+
this._enqueueNotify(frame(TYPE_CLOSE, f.sid)).catch(safetyCatch)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
s.stream.receive(b4a.from(f.payload))
|
|
296
367
|
} else if (f.type === TYPE_HELLO) {
|
|
297
368
|
const s = this._sessions.get(f.sidHex)
|
|
298
369
|
if (s) this._applyPeerName(s, f.payload)
|
|
299
370
|
} else if (f.type === TYPE_CLOSE) {
|
|
300
371
|
const s = this._sessions.get(f.sidHex)
|
|
301
372
|
if (s) {
|
|
302
|
-
this.
|
|
303
|
-
s.stream.remoteEnd()
|
|
373
|
+
this._reapSession(f.sidHex, s)
|
|
374
|
+
if (s.stream) s.stream.remoteEnd()
|
|
304
375
|
}
|
|
305
376
|
}
|
|
306
377
|
}
|
|
307
378
|
|
|
308
|
-
|
|
309
|
-
|
|
379
|
+
_openServerGatt(sidHex, session) {
|
|
380
|
+
const sid = session.sid
|
|
381
|
+
session.stream = new GattStream({
|
|
382
|
+
send: (payload) => this._enqueueNotify(frame(TYPE_DATA, sid, payload)),
|
|
383
|
+
onclose: () => this._closeServerSession(sidHex, sid)
|
|
384
|
+
})
|
|
385
|
+
session.conn = this._onChannel(session.stream, false, null)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Incoming l2cap channel: the central writes its 8-byte session id first,
|
|
389
|
+
// matching the channel to the session negotiated over the characteristic.
|
|
390
|
+
_onServerChannel(channel) {
|
|
391
|
+
if (this.closing || this.closed || this._suspended) {
|
|
392
|
+
try {
|
|
393
|
+
channel.destroy()
|
|
394
|
+
} catch (err) {
|
|
395
|
+
safetyCatch(err)
|
|
396
|
+
}
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
readSidPreamble(
|
|
400
|
+
channel,
|
|
401
|
+
SID_LEN,
|
|
402
|
+
(sid, leftover) => {
|
|
403
|
+
const sidHex = sid !== null ? b4a.toString(sid, 'hex') : null
|
|
404
|
+
const session = sidHex !== null ? this._sessions.get(sidHex) : undefined
|
|
405
|
+
if (!session || session.stream) {
|
|
406
|
+
try {
|
|
407
|
+
channel.destroy()
|
|
408
|
+
} catch (err) {
|
|
409
|
+
safetyCatch(err)
|
|
410
|
+
}
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
if (session.pipeTimer) clearTimeout(session.pipeTimer)
|
|
414
|
+
session.pipeTimer = null
|
|
415
|
+
session.stream = new L2CAPStream(channel, {
|
|
416
|
+
onclose: () => this._closeServerSession(sidHex, session.sid)
|
|
417
|
+
})
|
|
418
|
+
session.conn = this._onChannel(session.stream, false, null)
|
|
419
|
+
if (session.name && session.conn) session.conn._peerName = session.name
|
|
420
|
+
if (leftover.byteLength) session.stream.receive(leftover)
|
|
421
|
+
},
|
|
422
|
+
this._l2capTimeout
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
_reapSession(sidHex, session) {
|
|
427
|
+
if (session.pipeTimer) clearTimeout(session.pipeTimer)
|
|
428
|
+
session.pipeTimer = null
|
|
310
429
|
this._sessions.delete(sidHex)
|
|
430
|
+
this._cycleListener()
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
_closeServerSession(sidHex, sid) {
|
|
434
|
+
const session = this._sessions.get(sidHex)
|
|
435
|
+
if (!session) return
|
|
436
|
+
this._reapSession(sidHex, session)
|
|
311
437
|
this._enqueueNotify(frame(TYPE_CLOSE, sid)).catch(safetyCatch)
|
|
312
438
|
}
|
|
313
439
|
|
|
314
440
|
// ─── peer display name (hello frame) ──────────────────────────────────────
|
|
315
441
|
|
|
316
442
|
_helloPayload() {
|
|
317
|
-
|
|
443
|
+
const hello = { n: this.name || '' }
|
|
444
|
+
// servers advertise their l2cap listener so the central can open a channel
|
|
445
|
+
if (this.pipe === 'l2cap' && this._psm !== null) hello.p = this._psm
|
|
446
|
+
return b4a.from(JSON.stringify(hello))
|
|
318
447
|
}
|
|
319
448
|
|
|
449
|
+
/**
|
|
450
|
+
* @param {Uint8Array} payload
|
|
451
|
+
* @returns {{ name: string, psm: number | null } | null}
|
|
452
|
+
*/
|
|
320
453
|
_parseHello(payload) {
|
|
321
454
|
try {
|
|
322
|
-
const { n } = JSON.parse(b4a.toString(payload))
|
|
323
|
-
return
|
|
455
|
+
const { n, p } = JSON.parse(b4a.toString(payload))
|
|
456
|
+
return {
|
|
457
|
+
name: typeof n === 'string' ? n : '',
|
|
458
|
+
psm: Number.isInteger(p) ? p : null
|
|
459
|
+
}
|
|
324
460
|
} catch {
|
|
325
461
|
return null
|
|
326
462
|
}
|
|
327
463
|
}
|
|
328
464
|
|
|
329
465
|
_applyPeerName(session, payload) {
|
|
330
|
-
const
|
|
331
|
-
if (
|
|
332
|
-
session.name = name
|
|
333
|
-
if (session.conn) session.conn._peerName = name
|
|
466
|
+
const hello = this._parseHello(payload)
|
|
467
|
+
if (hello === null) return
|
|
468
|
+
session.name = hello.name
|
|
469
|
+
if (session.conn) session.conn._peerName = hello.name
|
|
334
470
|
this.emit('update')
|
|
335
471
|
}
|
|
336
472
|
|
|
@@ -408,6 +544,41 @@ export class BLETransport extends ReadyResource {
|
|
|
408
544
|
}
|
|
409
545
|
}
|
|
410
546
|
|
|
547
|
+
/**
|
|
548
|
+
* A radio power cycle invalidates the GATT service, advertising, scans,
|
|
549
|
+
* subscriptions and every open link, but the bookkeeping flags survive —
|
|
550
|
+
* without a reset the device never re-registers or re-advertises and goes
|
|
551
|
+
* dark until the app-level toggle is cycled. Reset so the poweredOn
|
|
552
|
+
* handlers bootstrap everything from scratch.
|
|
553
|
+
*/
|
|
554
|
+
_onRadioDown() {
|
|
555
|
+
this._serviceAdded = false
|
|
556
|
+
this._advertising = false
|
|
557
|
+
this._scanning = false
|
|
558
|
+
// the power cycle wiped the GATT db — the listener is gone with it
|
|
559
|
+
this._psm = null
|
|
560
|
+
for (const d of this._devices.values()) if (d.timer) clearTimeout(d.timer)
|
|
561
|
+
this._devices.clear()
|
|
562
|
+
for (const conn of this.peers.values()) {
|
|
563
|
+
try {
|
|
564
|
+
conn.destroy()
|
|
565
|
+
} catch (err) {
|
|
566
|
+
safetyCatch(err)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
for (const { stream, pipeTimer } of this._sessions.values()) {
|
|
570
|
+
if (pipeTimer) clearTimeout(pipeTimer)
|
|
571
|
+
try {
|
|
572
|
+
if (stream) stream.destroy()
|
|
573
|
+
} catch (err) {
|
|
574
|
+
safetyCatch(err)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
this._sessions.clear()
|
|
578
|
+
for (const item of this._notifyQueue) item.reject(new Error('radio down'))
|
|
579
|
+
this._notifyQueue = []
|
|
580
|
+
}
|
|
581
|
+
|
|
411
582
|
_onState(raw) {
|
|
412
583
|
const next = STATE[raw] ?? 'waiting'
|
|
413
584
|
if (next === this.state) return
|
|
@@ -441,7 +612,13 @@ export class BLETransport extends ReadyResource {
|
|
|
441
612
|
|
|
442
613
|
_onConnect(peripheral) {
|
|
443
614
|
this._device(peripheral.id).peripheral = peripheral
|
|
444
|
-
peripheral.on('error', () =>
|
|
615
|
+
peripheral.on('error', (err) => {
|
|
616
|
+
// platforms emit benign error events during openL2CAPChannel — aborting
|
|
617
|
+
// would hang up the link under the in-flight open. The open's own
|
|
618
|
+
// deadline covers a genuinely dead link.
|
|
619
|
+
if (peripheral._session?.upgrading) return
|
|
620
|
+
this._abortDial(peripheral, err?.message ?? 'peripheral-error')
|
|
621
|
+
})
|
|
445
622
|
peripheral.once('servicesDiscover', (services) => {
|
|
446
623
|
const svc = findByUUID(services, this.serviceUUID)
|
|
447
624
|
if (svc) peripheral.discoverCharacteristics(svc, [DATA_UUID])
|
|
@@ -470,48 +647,144 @@ export class BLETransport extends ReadyResource {
|
|
|
470
647
|
_startCentralSession(peripheral, char) {
|
|
471
648
|
const sid = randomBytes(SID_LEN)
|
|
472
649
|
const sidHex = b4a.toString(sid, 'hex')
|
|
473
|
-
peripheral._session = { sidHex, sid }
|
|
650
|
+
peripheral._session = { sidHex, sid, upgrading: false }
|
|
474
651
|
peripheral._char = char // suspend's goodbye writes reuse it
|
|
475
652
|
peripheral.on('notify', (_char, data) => this._onCentralNotify(peripheral, data))
|
|
476
|
-
const stream = new GattStream({
|
|
477
|
-
send: (payload) => this._centralSend(peripheral, char, frame(TYPE_DATA, sid, payload)),
|
|
478
|
-
onclose: () => {
|
|
479
|
-
this._centralSend(peripheral, char, frame(TYPE_CLOSE, sid)).catch(safetyCatch)
|
|
480
|
-
const d = this._devices.get(peripheral.id)
|
|
481
|
-
if (d) d.peripheral = null
|
|
482
|
-
try {
|
|
483
|
-
this.central.disconnect(peripheral)
|
|
484
|
-
} catch (err) {
|
|
485
|
-
safetyCatch(err)
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
})
|
|
489
|
-
peripheral._stream = stream
|
|
490
653
|
// open frame first: it registers the session on the server before any data
|
|
491
654
|
this._centralSend(peripheral, char, frame(TYPE_OPEN, sid)).catch(safetyCatch)
|
|
492
655
|
this._centralSend(peripheral, char, frame(TYPE_HELLO, sid, this._helloPayload())).catch(
|
|
493
656
|
safetyCatch
|
|
494
657
|
)
|
|
658
|
+
if (this.pipe === 'l2cap') {
|
|
659
|
+
// no stream yet — the server's hello carries the psm to open a channel
|
|
660
|
+
// to; the dial timer keeps running until the channel binds
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
this._openCentralGatt(peripheral, peripheral._session)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
_openCentralGatt(peripheral, sess) {
|
|
667
|
+
const stream = new GattStream({
|
|
668
|
+
send: (payload) =>
|
|
669
|
+
this._centralSend(peripheral, peripheral._char, frame(TYPE_DATA, sess.sid, payload)),
|
|
670
|
+
onclose: () => this._closeCentralSession(peripheral, sess)
|
|
671
|
+
})
|
|
672
|
+
peripheral._stream = stream
|
|
495
673
|
this._clearDial(peripheral.id)
|
|
496
674
|
peripheral._conn = this._onChannel(stream, true, peripheral.id)
|
|
675
|
+
if (peripheral._peerName && peripheral._conn) peripheral._conn._peerName = peripheral._peerName
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
_closeCentralSession(peripheral, sess) {
|
|
679
|
+
this._centralSend(peripheral, peripheral._char, frame(TYPE_CLOSE, sess.sid)).catch(safetyCatch)
|
|
680
|
+
// platforms reuse peripheral objects across reconnects — stale refs here
|
|
681
|
+
// would make the next session look live and get dropped
|
|
682
|
+
peripheral._stream = null
|
|
683
|
+
peripheral._session = null
|
|
684
|
+
const d = this._devices.get(peripheral.id)
|
|
685
|
+
if (d) d.peripheral = null
|
|
686
|
+
try {
|
|
687
|
+
this.central.disconnect(peripheral)
|
|
688
|
+
} catch (err) {
|
|
689
|
+
safetyCatch(err)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// One open attempt under a deadline; failure aborts the dial and the
|
|
694
|
+
// cooldown/redial cycle tries again.
|
|
695
|
+
async _openCentralL2CAP(peripheral, sess, psm) {
|
|
696
|
+
const gone = () =>
|
|
697
|
+
peripheral._session !== sess || this.closing || this.closed || this._suspended
|
|
698
|
+
sess.upgrading = true
|
|
699
|
+
try {
|
|
700
|
+
const channel = await this._openChannel(peripheral, psm)
|
|
701
|
+
if (gone()) {
|
|
702
|
+
if (channel) {
|
|
703
|
+
try {
|
|
704
|
+
channel.destroy()
|
|
705
|
+
} catch (err) {
|
|
706
|
+
safetyCatch(err)
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
if (!channel) {
|
|
712
|
+
this._abortDial(peripheral, 'l2cap-failed')
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
// sid preamble first: the server matches the channel to the session
|
|
716
|
+
channel.write(sess.sid)
|
|
717
|
+
const stream = new L2CAPStream(channel, {
|
|
718
|
+
onclose: () => this._closeCentralSession(peripheral, sess)
|
|
719
|
+
})
|
|
720
|
+
peripheral._stream = stream
|
|
721
|
+
this._clearDial(peripheral.id)
|
|
722
|
+
peripheral._conn = this._onChannel(stream, true, peripheral.id)
|
|
723
|
+
if (peripheral._peerName && peripheral._conn) {
|
|
724
|
+
peripheral._conn._peerName = peripheral._peerName
|
|
725
|
+
}
|
|
726
|
+
} finally {
|
|
727
|
+
sess.upgrading = false
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
_openChannel(peripheral, psm) {
|
|
732
|
+
return new Promise((resolve) => {
|
|
733
|
+
let done = false
|
|
734
|
+
const finish = (channel) => {
|
|
735
|
+
if (done) {
|
|
736
|
+
if (channel) {
|
|
737
|
+
try {
|
|
738
|
+
channel.destroy()
|
|
739
|
+
} catch (err) {
|
|
740
|
+
safetyCatch(err)
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
done = true
|
|
746
|
+
clearTimeout(timer)
|
|
747
|
+
peripheral.removeListener('channelOpen', finish)
|
|
748
|
+
resolve(channel || null)
|
|
749
|
+
}
|
|
750
|
+
const timer = setTimeout(() => finish(null), this._l2capTimeout)
|
|
751
|
+
if (timer.unref) timer.unref()
|
|
752
|
+
peripheral.once('channelOpen', finish)
|
|
753
|
+
try {
|
|
754
|
+
peripheral.openL2CAPChannel(psm)
|
|
755
|
+
} catch (err) {
|
|
756
|
+
safetyCatch(err)
|
|
757
|
+
finish(null)
|
|
758
|
+
}
|
|
759
|
+
})
|
|
497
760
|
}
|
|
498
761
|
|
|
499
762
|
_onCentralNotify(peripheral, data) {
|
|
500
763
|
const sess = peripheral._session
|
|
501
|
-
if (!sess
|
|
764
|
+
if (!sess) return
|
|
502
765
|
const f = parseFrame(data)
|
|
503
766
|
if (!f) return
|
|
504
767
|
if (f.sidHex !== sess.sidHex) return // not our session
|
|
505
|
-
if (f.type === TYPE_DATA)
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
768
|
+
if (f.type === TYPE_DATA) {
|
|
769
|
+
if (peripheral._stream) peripheral._stream.receive(b4a.from(f.payload))
|
|
770
|
+
} else if (f.type === TYPE_HELLO) {
|
|
771
|
+
const hello = this._parseHello(f.payload)
|
|
772
|
+
if (hello === null) return
|
|
773
|
+
peripheral._peerName = hello.name
|
|
774
|
+
if (peripheral._conn) peripheral._conn._peerName = hello.name
|
|
511
775
|
this.emit('update')
|
|
776
|
+
if (this.pipe === 'l2cap' && !peripheral._stream && !sess.upgrading) {
|
|
777
|
+
if (hello.psm === null) {
|
|
778
|
+
// the server has no l2cap listener — pipe mismatch, never degrade
|
|
779
|
+
this._abortDial(peripheral, 'no-l2cap')
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
this._openCentralL2CAP(peripheral, sess, hello.psm).catch(safetyCatch)
|
|
783
|
+
}
|
|
512
784
|
} else if (f.type === TYPE_CLOSE) {
|
|
513
785
|
peripheral._session = null
|
|
514
|
-
peripheral._stream.remoteEnd()
|
|
786
|
+
if (peripheral._stream) peripheral._stream.remoteEnd()
|
|
787
|
+
else this._abortDial(peripheral, 'refused')
|
|
515
788
|
}
|
|
516
789
|
}
|
|
517
790
|
|
|
@@ -722,9 +995,10 @@ export class BLETransport extends ReadyResource {
|
|
|
722
995
|
}
|
|
723
996
|
}
|
|
724
997
|
this.peers.clear()
|
|
725
|
-
for (const { stream } of this._sessions.values()) {
|
|
998
|
+
for (const { stream, pipeTimer } of this._sessions.values()) {
|
|
999
|
+
if (pipeTimer) clearTimeout(pipeTimer)
|
|
726
1000
|
try {
|
|
727
|
-
stream.destroy()
|
|
1001
|
+
if (stream) stream.destroy()
|
|
728
1002
|
} catch (err) {
|
|
729
1003
|
safetyCatch(err)
|
|
730
1004
|
}
|
|
@@ -732,6 +1006,9 @@ export class BLETransport extends ReadyResource {
|
|
|
732
1006
|
this._sessions.clear()
|
|
733
1007
|
for (const item of this._notifyQueue) item.reject(new Error('suspended'))
|
|
734
1008
|
this._notifyQueue = []
|
|
1009
|
+
// macOS only: a listener that lives through destroyed channels wedges the
|
|
1010
|
+
// resumed manager. iOS tolerates no manager surgery — keep its listener.
|
|
1011
|
+
if (PLATFORM === 'darwin') this._unpublishListener()
|
|
735
1012
|
this.state = 'off'
|
|
736
1013
|
this.emit('update')
|
|
737
1014
|
}
|
|
@@ -745,6 +1022,7 @@ export class BLETransport extends ReadyResource {
|
|
|
745
1022
|
this._suspended = false
|
|
746
1023
|
if (this.closing || this.closed) return
|
|
747
1024
|
this._advertising = false
|
|
1025
|
+
if (this.pipe === 'l2cap') this._publishListener()
|
|
748
1026
|
this._maybeAdvertise()
|
|
749
1027
|
this._startScan()
|
|
750
1028
|
const raw = this.central?.state ?? this.server?.state
|
|
@@ -781,6 +1059,9 @@ export class BLETransport extends ReadyResource {
|
|
|
781
1059
|
// tear this down when we're actually dropping the links
|
|
782
1060
|
for (const item of this._notifyQueue) item.reject(new Error('closed'))
|
|
783
1061
|
this._notifyQueue = []
|
|
1062
|
+
for (const { pipeTimer } of this._sessions.values()) {
|
|
1063
|
+
if (pipeTimer) clearTimeout(pipeTimer)
|
|
1064
|
+
}
|
|
784
1065
|
this._sessions.clear()
|
|
785
1066
|
}
|
|
786
1067
|
this.peers.clear()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Duplex } from 'streamx'
|
|
2
|
+
import b4a from 'b4a'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Byte pipe over an L2CAP channel — the GattStream-shaped wrapper so
|
|
6
|
+
* BLETransport can treat both pipes identically. The channel is already a
|
|
7
|
+
* reliable ordered duplex with credit-based flow control, so no framing or
|
|
8
|
+
* fragmentation is needed.
|
|
9
|
+
*
|
|
10
|
+
* @extends Duplex
|
|
11
|
+
*/
|
|
12
|
+
export class L2CAPStream extends Duplex {
|
|
13
|
+
/**
|
|
14
|
+
* @param {any} channel bare-bluetooth L2CAPChannel (a duplex).
|
|
15
|
+
* @param {object} [opts]
|
|
16
|
+
* @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
|
|
17
|
+
*/
|
|
18
|
+
constructor(channel, { onclose } = {}) {
|
|
19
|
+
super()
|
|
20
|
+
this.channel = channel
|
|
21
|
+
this._onclose = onclose || null
|
|
22
|
+
|
|
23
|
+
channel.on('data', (data) => {
|
|
24
|
+
// propagate read backpressure: hold the channel while our buffer is full
|
|
25
|
+
if (!this.push(b4a.from(data))) channel.pause()
|
|
26
|
+
})
|
|
27
|
+
channel.on('end', () => this.push(null))
|
|
28
|
+
channel.on('error', () => this.destroy())
|
|
29
|
+
channel.on('close', () => this.destroy())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_read(cb) {
|
|
33
|
+
this.channel.resume()
|
|
34
|
+
cb(null)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_write(chunk, cb) {
|
|
38
|
+
if (this.channel.write(chunk)) cb(null)
|
|
39
|
+
else this.channel.once('drain', () => cb(null))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
receive(buffer) {
|
|
43
|
+
this.push(buffer)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
remoteEnd() {
|
|
47
|
+
this.push(null)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_destroy(cb) {
|
|
51
|
+
const onclose = this._onclose
|
|
52
|
+
this._onclose = null
|
|
53
|
+
if (onclose) {
|
|
54
|
+
try {
|
|
55
|
+
onclose()
|
|
56
|
+
} catch {
|
|
57
|
+
// teardown is best-effort
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
this.channel.destroy()
|
|
62
|
+
} catch {
|
|
63
|
+
// channel may already be gone
|
|
64
|
+
}
|
|
65
|
+
cb(null)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Accumulate the session-id preamble a central writes first on a fresh
|
|
71
|
+
* channel, then hand (sid, leftover) to the callback exactly once. Owns the
|
|
72
|
+
* channel's data events until then so no bytes race past the switchover.
|
|
73
|
+
* Timeout → callback with (null, null).
|
|
74
|
+
*
|
|
75
|
+
* @param {any} channel
|
|
76
|
+
* @param {number} sidLen
|
|
77
|
+
* @param {(sid: Uint8Array | null, leftover: Uint8Array | null) => void} onSid
|
|
78
|
+
* @param {number} timeout
|
|
79
|
+
*/
|
|
80
|
+
export function readSidPreamble(channel, sidLen, onSid, timeout) {
|
|
81
|
+
let buf = b4a.alloc(0)
|
|
82
|
+
let done = false
|
|
83
|
+
|
|
84
|
+
const finish = (sid, leftover) => {
|
|
85
|
+
if (done) return
|
|
86
|
+
done = true
|
|
87
|
+
clearTimeout(timer)
|
|
88
|
+
channel.removeListener('data', onData)
|
|
89
|
+
onSid(sid, leftover)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const onData = (data) => {
|
|
93
|
+
buf = b4a.concat([buf, b4a.from(data)])
|
|
94
|
+
if (buf.byteLength >= sidLen) finish(buf.subarray(0, sidLen), buf.subarray(sidLen))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const timer = setTimeout(() => finish(null, null), timeout)
|
|
98
|
+
if (timer.unref) timer.unref()
|
|
99
|
+
channel.on('data', onData)
|
|
100
|
+
}
|
package/src/pairing/index.js
CHANGED
|
@@ -69,7 +69,8 @@ export class Pairing extends ReadyResource {
|
|
|
69
69
|
host = true,
|
|
70
70
|
inviteEncoding = null,
|
|
71
71
|
joinerEncoding = null,
|
|
72
|
-
onerror = safetyCatch
|
|
72
|
+
onerror = safetyCatch,
|
|
73
|
+
onconsume = null
|
|
73
74
|
} = {}) {
|
|
74
75
|
super()
|
|
75
76
|
if (!network) throw CeroError.REQUIRED('network')
|
|
@@ -82,6 +83,9 @@ export class Pairing extends ReadyResource {
|
|
|
82
83
|
this.inviteEncoding = inviteEncoding
|
|
83
84
|
this.joinerEncoding = joinerEncoding
|
|
84
85
|
this._onerror = onerror
|
|
86
|
+
// Fired with an invite's hex id when this instance stops serving it
|
|
87
|
+
// (single-use settled, or expired) — lets the owner drop its persisted row.
|
|
88
|
+
this.onconsume = onconsume
|
|
85
89
|
|
|
86
90
|
this._blind = null
|
|
87
91
|
this._member = null
|
|
@@ -168,12 +172,68 @@ export class Pairing extends ReadyResource {
|
|
|
168
172
|
seed: blind.seed,
|
|
169
173
|
publicKey: blind.publicKey,
|
|
170
174
|
invite,
|
|
171
|
-
reuse
|
|
175
|
+
reuse,
|
|
176
|
+
// minted here and possibly not yet persisted/replicated — syncRows must
|
|
177
|
+
// never drop it for being absent from the store
|
|
178
|
+
local: true
|
|
172
179
|
})
|
|
173
180
|
|
|
174
181
|
return invite.toString()
|
|
175
182
|
}
|
|
176
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Look up the in-memory record for a minted invite by its string form.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} inviteStr
|
|
188
|
+
* @returns {{ id: Uint8Array, seed: Uint8Array, publicKey: Uint8Array, invite: import('./invite.js').Invite, reuse: boolean } | null}
|
|
189
|
+
*/
|
|
190
|
+
recordOf(inviteStr) {
|
|
191
|
+
for (const record of this._invites.values()) {
|
|
192
|
+
if (record.invite.toString() === inviteStr) return record
|
|
193
|
+
}
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Reconcile the served-invite set with persisted rows (the room's `invites`
|
|
199
|
+
* collection). Adds unknown rows so ANY member replica can serve them across
|
|
200
|
+
* restarts; drops replicated records whose row disappeared (revoked or
|
|
201
|
+
* consumed elsewhere). Locally-minted records not yet visible as rows are
|
|
202
|
+
* kept.
|
|
203
|
+
*
|
|
204
|
+
* @param {Array<{ id: string, invite: Uint8Array, publicKey: Uint8Array, seed: Uint8Array, reuse?: boolean }>} rows
|
|
205
|
+
*/
|
|
206
|
+
syncRows(rows) {
|
|
207
|
+
const seen = new Set()
|
|
208
|
+
for (const row of rows) {
|
|
209
|
+
if (!row?.id || !row.invite || !row.publicKey || !row.seed) continue
|
|
210
|
+
seen.add(row.id)
|
|
211
|
+
const existing = this._invites.get(row.id)
|
|
212
|
+
if (existing) {
|
|
213
|
+
existing.local = false
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
let invite
|
|
217
|
+
try {
|
|
218
|
+
invite = Invite.parse(b4a.toString(row.invite))
|
|
219
|
+
} catch {
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
if (invite.expired) continue
|
|
223
|
+
this._invites.set(row.id, {
|
|
224
|
+
id: b4a.from(row.id, 'hex'),
|
|
225
|
+
seed: row.seed,
|
|
226
|
+
publicKey: row.publicKey,
|
|
227
|
+
invite,
|
|
228
|
+
reuse: !!row.reuse,
|
|
229
|
+
local: false
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
for (const [id, record] of this._invites) {
|
|
233
|
+
if (!seen.has(id) && !record.local) this._invites.delete(id)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
177
237
|
/**
|
|
178
238
|
* Forget a previously-minted invite. New candidates carrying it will be
|
|
179
239
|
* dropped silently.
|
|
@@ -273,6 +333,7 @@ export class Pairing extends ReadyResource {
|
|
|
273
333
|
if (!record) return
|
|
274
334
|
if (record.invite.expired) {
|
|
275
335
|
this._invites.delete(id)
|
|
336
|
+
this.onconsume?.(id)
|
|
276
337
|
return
|
|
277
338
|
}
|
|
278
339
|
|
|
@@ -292,7 +353,10 @@ export class Pairing extends ReadyResource {
|
|
|
292
353
|
seed: record.seed,
|
|
293
354
|
userData,
|
|
294
355
|
onsettle: () => {
|
|
295
|
-
if (!record.reuse)
|
|
356
|
+
if (!record.reuse) {
|
|
357
|
+
this._invites.delete(id)
|
|
358
|
+
this.onconsume?.(id)
|
|
359
|
+
}
|
|
296
360
|
}
|
|
297
361
|
})
|
|
298
362
|
|
|
@@ -33,8 +33,10 @@ export class BLETransport extends ReadyResource {
|
|
|
33
33
|
* @param {{ scanMode?: any }} [opts.scanOptions] Platform scan options (e.g. Android low-power).
|
|
34
34
|
* @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).
|
|
35
35
|
* @param {string} [opts.name] Local app-user display name, sent to peers over a hello frame.
|
|
36
|
+
* @param {'l2cap' | 'gatt'} [opts.pipe] Data pipe: 'l2cap' (default — a real channel per session, several times faster) or 'gatt' (framed characteristic stream). Both peers must match.
|
|
37
|
+
* @param {{ timeout?: number }} [opts.l2cap] Deadline for an l2cap channel open.
|
|
36
38
|
*/
|
|
37
|
-
constructor({ backend, network, uuid, nodeId, tag, maxOutbound, maxInbound, scanOptions, keepLinks, name }: {
|
|
39
|
+
constructor({ backend, network, uuid, nodeId, tag, maxOutbound, maxInbound, scanOptions, keepLinks, name, pipe, l2cap }: {
|
|
38
40
|
backend: any;
|
|
39
41
|
network: import("../index.js").Network;
|
|
40
42
|
uuid: Uint8Array;
|
|
@@ -47,6 +49,10 @@ export class BLETransport extends ReadyResource {
|
|
|
47
49
|
};
|
|
48
50
|
keepLinks?: boolean;
|
|
49
51
|
name?: string;
|
|
52
|
+
pipe?: "l2cap" | "gatt";
|
|
53
|
+
l2cap?: {
|
|
54
|
+
timeout?: number;
|
|
55
|
+
};
|
|
50
56
|
});
|
|
51
57
|
backend: any;
|
|
52
58
|
network: import("../index.js").Network;
|
|
@@ -60,10 +66,14 @@ export class BLETransport extends ReadyResource {
|
|
|
60
66
|
scanMode?: any;
|
|
61
67
|
};
|
|
62
68
|
keepLinks: boolean;
|
|
69
|
+
pipe: "l2cap" | "gatt";
|
|
70
|
+
_l2capTimeout: number;
|
|
63
71
|
state: string;
|
|
64
72
|
central: any;
|
|
65
73
|
server: any;
|
|
66
74
|
_dataChar: any;
|
|
75
|
+
/** the published l2cap listener's psm, advertised to centrals over hello */
|
|
76
|
+
_psm: any;
|
|
67
77
|
/** sessionId hex → { stream, sid } for server-side (peripheral) sessions */
|
|
68
78
|
_sessions: Map<any, any>;
|
|
69
79
|
/** serialized server notify queue: { frame, resolve, reject } */
|
|
@@ -92,22 +102,47 @@ export class BLETransport extends ReadyResource {
|
|
|
92
102
|
_device(id: any): any;
|
|
93
103
|
_prune(id: any): void;
|
|
94
104
|
_startServer(Service: any, Characteristic: any): void;
|
|
105
|
+
_publishListener(): void;
|
|
106
|
+
_unpublishListener(): void;
|
|
107
|
+
_cycleListener(): void;
|
|
95
108
|
_maybeAdvertise(): void;
|
|
96
109
|
_onWriteRequests(requests: any): void;
|
|
97
110
|
_onServerFrame(data: any): void;
|
|
111
|
+
_openServerGatt(sidHex: any, session: any): void;
|
|
112
|
+
_onServerChannel(channel: any): void;
|
|
113
|
+
_reapSession(sidHex: any, session: any): void;
|
|
98
114
|
_closeServerSession(sidHex: any, sid: any): void;
|
|
99
115
|
_helloPayload(): any;
|
|
100
|
-
|
|
116
|
+
/**
|
|
117
|
+
* @param {Uint8Array} payload
|
|
118
|
+
* @returns {{ name: string, psm: number | null } | null}
|
|
119
|
+
*/
|
|
120
|
+
_parseHello(payload: Uint8Array): {
|
|
121
|
+
name: string;
|
|
122
|
+
psm: number | null;
|
|
123
|
+
} | null;
|
|
101
124
|
_applyPeerName(session: any, payload: any): void;
|
|
102
125
|
_enqueueNotify(f: any): Promise<any>;
|
|
103
126
|
_drainNotify(): void;
|
|
104
127
|
_startScan(): void;
|
|
105
128
|
_armScanRestart(): void;
|
|
106
129
|
_stopScan(): void;
|
|
130
|
+
/**
|
|
131
|
+
* A radio power cycle invalidates the GATT service, advertising, scans,
|
|
132
|
+
* subscriptions and every open link, but the bookkeeping flags survive —
|
|
133
|
+
* without a reset the device never re-registers or re-advertises and goes
|
|
134
|
+
* dark until the app-level toggle is cycled. Reset so the poweredOn
|
|
135
|
+
* handlers bootstrap everything from scratch.
|
|
136
|
+
*/
|
|
137
|
+
_onRadioDown(): void;
|
|
107
138
|
_onState(raw: any): void;
|
|
108
139
|
_onDiscover(peripheral: any): void;
|
|
109
140
|
_onConnect(peripheral: any): void;
|
|
110
141
|
_startCentralSession(peripheral: any, char: any): void;
|
|
142
|
+
_openCentralGatt(peripheral: any, sess: any): void;
|
|
143
|
+
_closeCentralSession(peripheral: any, sess: any): void;
|
|
144
|
+
_openCentralL2CAP(peripheral: any, sess: any, psm: any): Promise<void>;
|
|
145
|
+
_openChannel(peripheral: any, psm: any): Promise<any>;
|
|
111
146
|
_onCentralNotify(peripheral: any, data: any): void;
|
|
112
147
|
_centralSend(peripheral: any, char: any, f: any): any;
|
|
113
148
|
_writeOnce(peripheral: any, char: any, f: any): Promise<any>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accumulate the session-id preamble a central writes first on a fresh
|
|
3
|
+
* channel, then hand (sid, leftover) to the callback exactly once. Owns the
|
|
4
|
+
* channel's data events until then so no bytes race past the switchover.
|
|
5
|
+
* Timeout → callback with (null, null).
|
|
6
|
+
*
|
|
7
|
+
* @param {any} channel
|
|
8
|
+
* @param {number} sidLen
|
|
9
|
+
* @param {(sid: Uint8Array | null, leftover: Uint8Array | null) => void} onSid
|
|
10
|
+
* @param {number} timeout
|
|
11
|
+
*/
|
|
12
|
+
export function readSidPreamble(channel: any, sidLen: number, onSid: (sid: Uint8Array | null, leftover: Uint8Array | null) => void, timeout: number): void;
|
|
13
|
+
/**
|
|
14
|
+
* Byte pipe over an L2CAP channel — the GattStream-shaped wrapper so
|
|
15
|
+
* BLETransport can treat both pipes identically. The channel is already a
|
|
16
|
+
* reliable ordered duplex with credit-based flow control, so no framing or
|
|
17
|
+
* fragmentation is needed.
|
|
18
|
+
*
|
|
19
|
+
* @extends Duplex
|
|
20
|
+
*/
|
|
21
|
+
export class L2CAPStream extends Duplex<import("streamx").DuplexEvents> {
|
|
22
|
+
/**
|
|
23
|
+
* @param {any} channel bare-bluetooth L2CAPChannel (a duplex).
|
|
24
|
+
* @param {object} [opts]
|
|
25
|
+
* @param {() => void} [opts.onclose] Called once on teardown (send a close frame, disconnect).
|
|
26
|
+
*/
|
|
27
|
+
constructor(channel: any, { onclose }?: {
|
|
28
|
+
onclose?: () => void;
|
|
29
|
+
});
|
|
30
|
+
channel: any;
|
|
31
|
+
_onclose: () => void;
|
|
32
|
+
_read(cb: any): void;
|
|
33
|
+
_write(chunk: any, cb: any): void;
|
|
34
|
+
receive(buffer: any): void;
|
|
35
|
+
remoteEnd(): void;
|
|
36
|
+
_destroy(cb: any): void;
|
|
37
|
+
}
|
|
38
|
+
import { Duplex } from 'streamx';
|
package/types/pairing/index.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export class Pairing extends ReadyResource {
|
|
|
57
57
|
*/
|
|
58
58
|
static isInvite(str: unknown): boolean;
|
|
59
59
|
/** @param {PairingOpts} [opts] */
|
|
60
|
-
constructor({ network, identity, topic, host, inviteEncoding, joinerEncoding, onerror }?: PairingOpts);
|
|
60
|
+
constructor({ network, identity, topic, host, inviteEncoding, joinerEncoding, onerror, onconsume }?: PairingOpts);
|
|
61
61
|
network: import("../index.js").Network;
|
|
62
62
|
identity: import("../index.js").Identity;
|
|
63
63
|
topic: Uint8Array<ArrayBufferLike>;
|
|
@@ -65,6 +65,7 @@ export class Pairing extends ReadyResource {
|
|
|
65
65
|
inviteEncoding: any;
|
|
66
66
|
joinerEncoding: any;
|
|
67
67
|
_onerror: (err: Error) => void;
|
|
68
|
+
onconsume: any;
|
|
68
69
|
_blind: any;
|
|
69
70
|
_member: any;
|
|
70
71
|
_invites: Map<any, any>;
|
|
@@ -76,6 +77,35 @@ export class Pairing extends ReadyResource {
|
|
|
76
77
|
* @returns {Promise<string>}
|
|
77
78
|
*/
|
|
78
79
|
createInvite({ role, expiresIn, data, reuse }?: CreateInviteOpts): Promise<string>;
|
|
80
|
+
/**
|
|
81
|
+
* Look up the in-memory record for a minted invite by its string form.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} inviteStr
|
|
84
|
+
* @returns {{ id: Uint8Array, seed: Uint8Array, publicKey: Uint8Array, invite: import('./invite.js').Invite, reuse: boolean } | null}
|
|
85
|
+
*/
|
|
86
|
+
recordOf(inviteStr: string): {
|
|
87
|
+
id: Uint8Array;
|
|
88
|
+
seed: Uint8Array;
|
|
89
|
+
publicKey: Uint8Array;
|
|
90
|
+
invite: import("./invite.js").Invite;
|
|
91
|
+
reuse: boolean;
|
|
92
|
+
} | null;
|
|
93
|
+
/**
|
|
94
|
+
* Reconcile the served-invite set with persisted rows (the room's `invites`
|
|
95
|
+
* collection). Adds unknown rows so ANY member replica can serve them across
|
|
96
|
+
* restarts; drops replicated records whose row disappeared (revoked or
|
|
97
|
+
* consumed elsewhere). Locally-minted records not yet visible as rows are
|
|
98
|
+
* kept.
|
|
99
|
+
*
|
|
100
|
+
* @param {Array<{ id: string, invite: Uint8Array, publicKey: Uint8Array, seed: Uint8Array, reuse?: boolean }>} rows
|
|
101
|
+
*/
|
|
102
|
+
syncRows(rows: Array<{
|
|
103
|
+
id: string;
|
|
104
|
+
invite: Uint8Array;
|
|
105
|
+
publicKey: Uint8Array;
|
|
106
|
+
seed: Uint8Array;
|
|
107
|
+
reuse?: boolean;
|
|
108
|
+
}>): void;
|
|
79
109
|
/**
|
|
80
110
|
* Forget a previously-minted invite. New candidates carrying it will be
|
|
81
111
|
* dropped silently.
|