@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.
- package/README.md +14 -12
- package/package.json +13 -4
- package/src/blobs/index.js +1 -1
- package/src/database/bootstrap.js +2 -2
- package/src/database/dispatch.js +41 -6
- package/src/database/index.js +177 -17
- package/src/identity/index.js +15 -1
- package/src/lib/errors.js +8 -0
- package/src/lib/utils.js +33 -12
- package/src/network/bluetooth.js +804 -0
- package/src/network/gatt-stream.js +59 -0
- package/src/network/index.js +121 -8
- package/src/pairing/index.js +6 -6
- package/src/rpc/index.js +2 -2
- package/src/storage/index.js +30 -8
- package/types/database/dispatch.d.ts +2 -1
- package/types/database/index.d.ts +34 -8
- package/types/identity/index.d.ts +9 -0
- package/types/lib/errors.d.ts +6 -0
- package/types/lib/utils.d.ts +4 -6
- package/types/network/bluetooth.d.ts +162 -0
- package/types/network/gatt-stream.d.ts +26 -0
- package/types/network/index.d.ts +36 -1
- package/types/storage/index.d.ts +7 -1
- package/src/CLAUDE.md +0 -3
- package/src/database/CLAUDE.md +0 -3
- package/src/identity/CLAUDE.md +0 -3
- package/src/lib/CLAUDE.md +0 -3
- package/src/network/CLAUDE.md +0 -3
- package/src/rpc/CLAUDE.md +0 -3
- package/src/storage/CLAUDE.md +0 -3
|
@@ -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
|
+
}
|