BLE transport architecture V3: one engine domain, capability ports, feature-owned state (#1498)

* Make peer registry and local announce state lock-backed

The main actor answered isPeerConnected/peerNickname/currentPeerSnapshots
and flipped runtime capability bits by blocking on collectionsQueue behind
whatever transport work was in flight. Peer state now lives in a
lock-backed BLEPeerRegistryStore (every registry mutation is a single
whole-transition method, so readers never observe a torn state), and the
runtime capability bits move into BLELocalIdentityStateStore next to the
identity they ride announces with. No transport entry point called from
the main actor blocks on a transport queue for peer state anymore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move BLE link egress/ingress buffers to bleQueue ownership

pendingPeripheralWrites, pendingNotifications, and pendingWriteBuffers
were collectionsQueue-guarded, but every producer and drain already runs
on bleQueue next to the CoreBluetooth objects they feed — each access
paid a cross-queue barrier for state that never leaves the radio thread,
and the notification drain even invoked peripheralManager.updateValue
from the collections queue. They are now bleQueue-confined like the link
state store: CB delegate callbacks and drains touch them directly, and
the few engine-side entry points hop to bleQueue (the direction the
transport's sync-edge order already allows). This clears most
bleQueue-to-collectionsQueue sync edges ahead of merging the collections
queue into the message queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Stop bleQueue maintenance and status paths from blocking on collectionsQueue

The traffic-burst tracker becomes a lock-backed monitor (written by the
receive pipeline, read by scan-duty adaptation and announce pacing on
bleQueue), the status-log peer summary and topology refresh read the
already lock-backed registry directly, and the stalled-fragment reap
moves to an async collections hop with the gossip resync request inside
it. bleQueue no longer sync-waits on the collections queue anywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Unify the message and collections queues into one serial engine queue

The old model ran a concurrent message queue over a second concurrent
collections queue whose barrier flags served as the real mutual
exclusion — every field carried an ownership comment, and correctness
lived in per-site discipline. The message queue is now a single serial
engine queue that owns all mesh protocol state; the collections queue,
its 98 sync/async hops, and every barrier flag are gone. Cross-thread
callers go through onEngine, which documents and (in debug) enforces
the transport's sync-edge order: main and test threads may block on the
engine, the engine may block on bleQueue and the crypto/identity
queues, and nothing may block the other way.

The debug trap caught two latent inversions the leaf-lock structure had
been masking: the verified-announce rebind path re-resolved the ingress
link through the engine from inside its bleQueue critical section (it
now receives the already-resolved link), and the noise
session-generation closures sync-re-entered the engine from the noise
manager's queue while their own engine slot was blocked on it (they now
touch engine state directly, which the held slot makes exclusive).

BLE throughput is orders of magnitude below what one serial queue
sustains; the full suite runs at identical speed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Wire gateway/bridge/panic features to capability ports, not BLEService

App wiring discovered mesh-only features by casting the Transport to
the concrete BLEService class in nine places. Those surfaces are now
three capability protocols — BluetoothStateReporting,
PanicResettingTransport, and MeshBridgingTransport — discovered with
as? like any optional capability, so the bootstrapper, panic flow, and
lifecycle coordinator no longer name the concrete transport at all. A
future second mesh transport picks up gateway/bridge wiring and the
panic lifecycle by conforming, and the remaining Transport god-protocol
requirements can migrate to the same pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extract mesh-ping diagnostics state into a pure engine-confined tracker

First slice of the feature-module direction: BLEMeshPingTracker owns the
outstanding-probe map and the per-link inbound response budget as pure
state (register/resolve/expire/reset), so the security invariants — a
pong only resolves against the probed peer, the budget keys on the
ingress link because claimed senders are forgeable, panic reset drops
probes and budget together — are now unit-tested without queues or
radios. The transport keeps only packet I/O, timers, and main-actor
delivery around it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document the V3 transport architecture and remaining roadmap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Resolve the pass-6 review findings and the proof-timeout drain defect

Periphery: the registry store's unused forwarders are gone (the struct
method stays — it has direct tests). F1: refreshPeerIdentity,
deliverBridgedEnvelope, and the three panic fences route through
onEngine, so every sync entry onto the engine now carries the bleQueue
trap. F2: the registry-store ownership comments state the real writer
set (engine plus the two bleQueue link-drop paths). F7:
BLEQueueContractTests pins the contract — only onEngine may sync-enter
the engine, transport code never sync-dispatches to main, and the
collections queue stays deleted — with a queue-contract-ok waiver for
the two sanctioned lines.

The real defect behind the timeoutRestoredSession CI flake: a
timeout-restore parks the outbound queues until the convergence retry,
but the capability-proof watchdog armed at the original authentication
kept draining them when it fired — encrypting the parked traffic under
restored keys the counterpart may have discarded, the exact silent loss
the defer path exists to prevent. Deferred peers are now tracked and
the watchdog drain respects the same rule; the test fires the watchdog
deterministically inside the deferred window instead of losing that
race only on stalled runners.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extract private-media session state into a lock-backed store

The six generation-keyed maps plus the convergence-deferral set move
out of BLEService into BLEPrivateMediaSessionStore, each transition one
whole method under a leaf lock with direct unit tests (generation
rotation rejects mismatched waiters, stale proofs cannot classify a
replacement session, expiry requires the live deadline identity, clears
rebase waiters onto a nil-generation deadline, peer-state sends are
once per generation per kind).

Being a leaf lock also simplifies two contracts: the send policy is now
answered entirely from locks (the main actor no longer sync-enters the
engine for it), and the noise-manager critical sections call ordinary
store methods instead of relying on the held-engine-slot direct-access
subtlety.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split the mesh-only Transport surface into capability protocols

Transport kept ~50 requirements that only the BLE mesh implements —
files/private media, voice, courier, groups, board, diagnostics,
verification, archive — held together by an extension of inert
defaults, so every call site compiled against a surface most transports
faked. Those are now eight capability protocols (MeshFileTransferring,
MeshVoiceStreaming, MeshCourierTransporting, MeshGroupMessaging,
MeshBoardBroadcasting, MeshDiagnosing, MeshVerifying,
MeshPublicArchiving) discovered with as?, joining the bridging/panic
ports from the previous pass. Consumers resolve the capability they
need; where the old defaults encoded a safe floor the caller keeps it
explicitly (private-media policy degrades to blockedDowngrade). The
inert-defaults extension is deleted, along with the never-implemented
acceptPendingFile/declinePendingFile pair. NostrTransport is untouched
— it only ever implemented the core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update the V3 doc for the completed feature-peeling and Transport split

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop the dead three-argument sendFilePrivate overload

Every production caller goes through the allowLegacyFallback variant;
the short form only existed as a Transport-era forwarding default.
Tests that used it on the concrete service now state the fallback
decision explicitly, which is the point of the parameter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Decide the link-auth boundary: bindings become engine-owned

The atomicity that keeps link-auth on bleQueue exists to stop a binding
from changing between a security check and its action; once every
rebind is an engine operation, the engine's serial slot gives the same
guarantee, the stolen-link residual is unchanged (directed payloads are
Noise ciphertext), and the receive path lands in its sans-I/O shape —
the link layer reports bytes-plus-linkID and the engine resolves the
sender. Records the extraction order too: the binding-free radio half
first (after #1521 lands — it collides in the scanPlan region), then
bindings, then the delegates behind the port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix two bleQueue-to-engine sync edges the queue merge created

The collections-to-engine conversion turned two formerly leaf-lock
sync calls into onEngine calls reachable from bleQueue, where the
debug trap (correctly) aborts: flushDirectedSpool runs from bleQueue
maintenance and now hops to the engine asynchronously, and ingress
recording — which must answer the duplicate gate on bleQueue the
moment a frame decodes — moves to a lock-backed BLEIngressLinkStore
read by the engine's relay and routing decisions.

Unit suites never hit either path (no CoreBluetooth managers means no
maintenance timer and no live receive path); the iOS simulator job
boots the real app as its test host, which is exactly where the
maintenance trap fired. The ingress one would have trapped a real
device on its first received packet — worth a device pass before
release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Route all deferred engine work through an injectable scheduler

Relay jitter, announce delays, the ping and capability-proof deadlines,
notification retry backoff, and fragment pacing all reached the engine
through raw messageQueue.asyncAfter with product constants as deadlines
— the hidden-elapsed-deadline flake class that the test-timing hygiene
rules exist to contain, testable only by racing the wall clock.
BLEEngineScheduling is now the transport's single source of engine
delay: production is a thin veneer over the engine queue, tests inject
a manually advanced clock whose advance() returns only after the
released work has finished on the engine. The queue-contract test pins
the seam (no raw messageQueue.asyncAfter), and the ping deadline gets
the pattern's proof: the real 10s constant asserted in milliseconds —
must not fire early, fires exactly once at the deadline, stays consumed
after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Assert the armed deadline count in the injected-clock ping test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-07-29 16:03:20 +01:00 committed by GitHub
parent eadd3a20c1
commit c6b7096b2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 2063 additions and 918 deletions

View File

@ -85,7 +85,8 @@ final class AppChromeModel: ObservableObject {
/// neighbor claim but never announced to us) fall back to a short ID.
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
let mesh = chatViewModel.meshService
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
guard let diagnostics = mesh as? MeshDiagnosing,
let snapshot = diagnostics.currentMeshTopology() else { return .empty }
let nicknames = mesh.getPeerNicknames()
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in

View File

@ -0,0 +1,39 @@
import Foundation
/// Schedules deferred engine work: relay jitter, announce delays, protocol
/// deadlines (ping, capability proof), notification retry backoff, and
/// fragment pacing.
///
/// This is the transport's only source of engine-side delay. Production
/// wraps the engine queue's `asyncAfter`; tests inject a manually advanced
/// clock so timer-driven behavior is asserted deterministically instead of
/// racing the wall clock product constants used as deadlines are exactly
/// the hidden-elapsed-deadline flake class the test-timing hygiene rules
/// exist to contain.
protocol BLEEngineScheduling: AnyObject {
/// Called once by the transport with its engine queue. Scheduled work
/// always executes there: deferred bodies touch engine-confined state.
func activate(engineQueue: DispatchQueue)
/// Runs `work` on the engine queue after `delay`, honoring
/// `DispatchWorkItem` cancellation.
func schedule(after delay: TimeInterval, execute work: DispatchWorkItem)
}
extension BLEEngineScheduling {
func schedule(after delay: TimeInterval, _ body: @escaping () -> Void) {
schedule(after: delay, execute: DispatchWorkItem(block: body))
}
}
/// Production scheduler: a thin veneer over the engine queue.
final class BLEEngineDispatchScheduler: BLEEngineScheduling {
private var queue: DispatchQueue?
func activate(engineQueue: DispatchQueue) {
queue = engineQueue
}
func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) {
queue?.asyncAfter(deadline: .now() + delay, execute: work)
}
}

View File

@ -124,3 +124,45 @@ struct BLEIngressLinkRegistry {
packet.isRSR && packet.ttl == 0
}
}
/// Lock-backed shared ownership of the ingress-link registry. Ingress is
/// recorded on bleQueue the moment a frame decodes (the link identity is
/// only known there, and the duplicate-ingress gate must answer before
/// the packet is handed to the engine), while relay and routing decisions
/// read it from the engine. Every registry mutation is a single
/// whole-transition method, so readers never observe a torn state.
final class BLEIngressLinkStore: @unchecked Sendable {
private let lock = NSLock()
private var registry = BLEIngressLinkRegistry()
var isEmpty: Bool {
lock.withLock { registry.isEmpty }
}
func removeAll() {
lock.withLock { registry.removeAll() }
}
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
lock.withLock { registry.record(for: packet) }
}
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
lock.withLock { registry.link(for: packet) }
}
func recordIfNew(
_ packet: BitchatPacket,
link: BLEIngressLinkID,
peerID: PeerID,
lifetime: TimeInterval
) -> Bool {
lock.withLock {
registry.recordIfNew(packet, link: link, peerID: peerID, lifetime: lifetime)
}
}
func prune(before cutoff: Date) {
lock.withLock { registry.prune(before: cutoff) }
}
}

View File

@ -5,6 +5,20 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable {
let peerID: PeerID
let peerIDData: Data
let nickname: String
/// Runtime-toggled capability bits (e.g. the internet-gateway toggle)
/// ORed into `PeerCapabilities.localSupported` for every announce.
let runtimeCapabilities: PeerCapabilities
/// Rendezvous cell advertised while bridging; rides announces only
/// while the `.bridge` capability is enabled.
let bridgeGeohash: String?
var advertisedCapabilities: PeerCapabilities {
PeerCapabilities.localSupported.union(runtimeCapabilities)
}
var advertisedBridgeGeohash: String? {
runtimeCapabilities.contains(.bridge) ? bridgeGeohash : nil
}
}
/// Lock-backed local identity state shared by the transport's message,
@ -12,8 +26,8 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable {
///
/// `peerID` and its binary wire representation must change as one unit during
/// panic rotation. A snapshot also gives announce construction one consistent
/// view of the nickname and identity instead of reading three independently
/// mutable properties across queues.
/// view of the nickname, identity, and advertised capabilities instead of
/// reading independently mutable properties across queues.
final class BLELocalIdentityStateStore: @unchecked Sendable {
private let lock = NSLock()
private var state: BLELocalIdentitySnapshot
@ -25,7 +39,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
state = BLELocalIdentitySnapshot(
peerID: peerID,
peerIDData: Data(hexString: peerID.id) ?? Data(),
nickname: nickname
nickname: nickname,
runtimeCapabilities: [],
bridgeGeohash: nil
)
}
@ -38,7 +54,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
state = BLELocalIdentitySnapshot(
peerID: state.peerID,
peerIDData: state.peerIDData,
nickname: nickname
nickname: nickname,
runtimeCapabilities: state.runtimeCapabilities,
bridgeGeohash: state.bridgeGeohash
)
}
}
@ -48,8 +66,48 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
state = BLELocalIdentitySnapshot(
peerID: peerID,
peerIDData: Data(hexString: peerID.id) ?? Data(),
nickname: state.nickname
nickname: state.nickname,
runtimeCapabilities: state.runtimeCapabilities,
bridgeGeohash: state.bridgeGeohash
)
}
}
/// Flips a runtime capability bit. Returns whether anything changed.
@discardableResult
func setCapability(_ capability: PeerCapabilities, enabled: Bool) -> Bool {
lock.withLock {
var capabilities = state.runtimeCapabilities
if enabled {
capabilities.insert(capability)
} else {
capabilities.remove(capability)
}
guard capabilities != state.runtimeCapabilities else { return false }
state = BLELocalIdentitySnapshot(
peerID: state.peerID,
peerIDData: state.peerIDData,
nickname: state.nickname,
runtimeCapabilities: capabilities,
bridgeGeohash: state.bridgeGeohash
)
return true
}
}
/// Sets the bridged rendezvous cell. Returns whether anything changed.
@discardableResult
func setBridgeGeohash(_ cell: String?) -> Bool {
lock.withLock {
guard cell != state.bridgeGeohash else { return false }
state = BLELocalIdentitySnapshot(
peerID: state.peerID,
peerIDData: state.peerIDData,
nickname: state.nickname,
runtimeCapabilities: state.runtimeCapabilities,
bridgeGeohash: cell
)
return true
}
}
}

View File

@ -0,0 +1,62 @@
import BitFoundation
import Foundation
struct BLEMeshPingProbe {
let peerID: PeerID
let sentAt: Date
let lifecycleGeneration: UInt64
let completion: @MainActor (MeshPingResult?) -> Void
let timeout: DispatchWorkItem
}
/// Engine-confined /ping diagnostics state: outstanding probes keyed by
/// their unguessable nonce, plus the inbound response budget.
///
/// The budget is keyed by the ingress link (the directly connected peer
/// that delivered the packet), never the packet-claimed sender: pings are
/// unsigned, so the claimed sender is attacker-controlled and rotating it
/// would reset the budget, turning a directed unencrypted probe into an
/// amplification primitive.
///
/// Pure state the transport owns packet I/O, timers, and main-actor
/// completion delivery around it.
struct BLEMeshPingTracker {
private var pendingProbes: [Data: BLEMeshPingProbe] = [:]
private var responseLimiter = SyncResponseRateLimiter(
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
window: TransportConfig.meshPingInboundWindowSeconds
)
mutating func register(_ probe: BLEMeshPingProbe, nonce: Data) {
pendingProbes[nonce] = probe
}
/// Resolves a pong against its outstanding probe. The echoed nonce plus
/// the sender check bind the reply to the probed peer.
mutating func resolve(nonce: Data, from peerID: PeerID) -> BLEMeshPingProbe? {
guard pendingProbes[nonce]?.peerID == peerID else { return nil }
return pendingProbes.removeValue(forKey: nonce)
}
/// Removes a timed-out probe so its completion can fire once with nil.
mutating func expire(nonce: Data) -> BLEMeshPingProbe? {
pendingProbes.removeValue(forKey: nonce)
}
/// Whether an inbound ping delivered by this link is within budget.
mutating func shouldRespond(toLink linkPeerID: PeerID, now: Date) -> Bool {
responseLimiter.shouldRespond(to: linkPeerID, now: now)
}
/// Drops all probes and restores a fresh response budget (panic wipe).
/// Returns the orphaned timeout work items for the caller to cancel.
mutating func reset() -> [DispatchWorkItem] {
let timeouts = pendingProbes.values.map(\.timeout)
pendingProbes.removeAll()
responseLimiter = SyncResponseRateLimiter(
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
window: TransportConfig.meshPingInboundWindowSeconds
)
return timeouts
}
}

View File

@ -0,0 +1,84 @@
import BitFoundation
import Foundation
/// Lock-backed shared ownership of the peer registry, readable from any
/// queue or the main actor without hopping onto a transport queue.
///
/// Mutations come only from the transport's own serial queues the
/// engine, plus the bleQueue link-drop paths that mark a peer
/// disconnected and the lock serializes them against each other and
/// against readers, so the main actor answers questions like
/// `isPeerConnected` without blocking behind in-flight transport work.
/// Every `BLEPeerRegistry` mutation is a single whole-transition method,
/// so a reader between two mutations always observes a valid pre- or
/// post-state, never a torn one.
///
/// Closures passed to `read`/`mutate` run under the (non-recursive) lock
/// and must not call back into the store.
final class BLEPeerRegistryStore: @unchecked Sendable {
private let lock = NSLock()
private var registry = BLEPeerRegistry()
/// One consistent view across multiple registry reads.
func read<T>(_ body: (BLEPeerRegistry) -> T) -> T {
lock.withLock { body(registry) }
}
func mutate<T>(_ body: (inout BLEPeerRegistry) -> T) -> T {
lock.withLock { body(&registry) }
}
// MARK: - Single-question reads
var isEmpty: Bool { read { $0.isEmpty } }
var peerIDs: [PeerID] { read { $0.peerIDs } }
var connectedCount: Int { read { $0.connectedCount } }
var connectedPeerIDs: [PeerID] { read { $0.connectedPeerIDs } }
var connectedRoutingData: [Data] { read { $0.connectedRoutingData } }
var snapshotByID: [PeerID: BLEPeerInfo] { read { $0.snapshotByID } }
func info(for peerID: PeerID) -> BLEPeerInfo? {
read { $0.info(for: peerID) }
}
func isConnected(_ peerID: PeerID) -> Bool {
read { $0.isConnected(peerID) }
}
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
read { $0.isReachable(peerID, now: now) }
}
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
read { $0.nickname(for: peerID, connectedOnly: connectedOnly) }
}
func fingerprint(for peerID: PeerID) -> String? {
read { $0.fingerprint(for: peerID) }
}
func capabilities(for peerID: PeerID) -> PeerCapabilities {
read { $0.capabilities(for: peerID) }
}
func advertisedBridgeGeohash() -> String? {
read { $0.advertisedBridgeGeohash() }
}
func displayNicknames(selfNickname: String) -> [PeerID: String] {
read { $0.displayNicknames(selfNickname: selfNickname) }
}
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
read { $0.transportSnapshots(selfNickname: selfNickname) }
}
/// Peers advertising `capability` that are reachable now, in one
/// consistent view.
func reachablePeers(advertising capability: PeerCapabilities, now: Date) -> [PeerID] {
read { registry in
registry.peers(advertising: capability)
.filter { registry.isReachable($0, now: now) }
}
}
}

View File

@ -0,0 +1,363 @@
import BitFoundation
import Foundation
struct BLEAuthenticatedPeerStateObservation {
let fingerprint: String
let sessionGeneration: UUID
let capabilities: PeerCapabilities
}
struct BLEPrivateMediaProofTimeoutMarker {
let fingerprint: String
let sessionGeneration: UUID?
}
struct BLEPrivateMediaProofWatchdog {
let fingerprint: String
let sessionGeneration: UUID
let timeoutNonce: UUID
}
struct BLEPendingPrivateMediaPolicyResolution {
let fingerprint: String
var sessionGeneration: UUID?
var timeoutNonce: UUID
var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void]
}
struct BLEAuthenticatedPeerStateSendProgress {
let sessionGeneration: UUID
var sentInitial = false
var sentEcho = false
}
/// Lock-backed private-media session state: which Noise generation each
/// peer's capability proof, peer-state exchange, and policy waiters are
/// bound to. A fresh Noise authentication rotates the generation UUID, so
/// stale proof timers and proof packets cannot classify a replacement
/// session.
///
/// Lock-backed rather than engine-confined for two reasons: the send
/// policy is answered synchronously on the main actor, and several
/// transitions run inside noise-manager critical sections that the engine
/// is sync-waiting on (where re-entering the engine would self-deadlock,
/// but taking a leaf lock is safe). Every method is one whole transition
/// under the lock, so no caller can observe a torn intermediate state.
final class BLEPrivateMediaSessionStore: @unchecked Sendable {
private let lock = NSLock()
private var sessionGenerations: [PeerID: UUID] = [:]
private var authenticatedStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:]
private var proofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:]
private var proofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:]
private var pendingPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:]
private var stateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:]
/// Peers whose parked outbound queues must stay parked until the
/// convergence retry re-authenticates: a timeout-restore brings back
/// keys the counterpart may have already discarded, so nothing not
/// even the capability-proof watchdog may drain the queues under
/// them. Set on the deferred restore transition, cleared by any
/// transition that is allowed to drain.
private var outboundConvergenceDeferred: Set<PeerID> = []
// MARK: Reads
func currentGeneration(for peerID: PeerID) -> UUID? {
lock.withLock { sessionGenerations[peerID] }
}
/// The exact current generation iff it authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func receiptSessionGeneration(for peerID: PeerID, currentNoiseGeneration: UUID?) -> UUID? {
lock.withLock {
guard let generation = sessionGenerations[peerID],
generation == currentNoiseGeneration,
let authenticated = authenticatedStates[peerID],
authenticated.sessionGeneration == generation,
authenticated.capabilities.contains(.privateMedia),
authenticated.capabilities.contains(.privateMediaReceipts) else {
return nil
}
return generation
}
}
/// One consistent view of the state the send-policy calculus needs.
func policyInputs(for peerID: PeerID) -> (
sessionGeneration: UUID?,
authenticatedState: BLEAuthenticatedPeerStateObservation?,
timedOut: BLEPrivateMediaProofTimeoutMarker?
) {
lock.withLock {
(
sessionGenerations[peerID],
authenticatedStates[peerID],
proofTimeoutMarkers[peerID]
)
}
}
func hasPendingPolicyResolution(for peerID: PeerID) -> Bool {
lock.withLock { pendingPolicyResolutions[peerID] != nil }
}
/// The live proof-timeout identity for a peer (watchdog first, then a
/// registered waiter) what a forced/expired timeout must present.
func proofTimeoutTarget(for peerID: PeerID) -> (fingerprint: String, generation: UUID?, nonce: UUID)? {
lock.withLock {
if let watchdog = proofWatchdogs[peerID] {
return (watchdog.fingerprint, watchdog.sessionGeneration, watchdog.timeoutNonce)
}
if let pending = pendingPolicyResolutions[peerID] {
return (pending.fingerprint, pending.sessionGeneration, pending.timeoutNonce)
}
return nil
}
}
// MARK: Generation transitions
/// Installs a freshly authenticated generation: rotates the proof
/// watchdog, resets peer-state send progress, and re-binds any pending
/// policy waiters whose fingerprint still matches (mismatched waiters
/// are rejected and returned for completion). Returns nil when the
/// generation is already current the same-generation reconciliation
/// path, which must not re-arm proof machinery.
func beginAuthenticatedGeneration(
for peerID: PeerID,
fingerprint: String,
generation: UUID
) -> (watchdogNonce: UUID, rejected: [@MainActor (PrivateMediaSendPolicy) -> Void])? {
lock.withLock {
guard sessionGenerations[peerID] != generation else { return nil }
let watchdogNonce = UUID()
sessionGenerations[peerID] = generation
authenticatedStates.removeValue(forKey: peerID)
proofTimeoutMarkers.removeValue(forKey: peerID)
proofWatchdogs[peerID] = BLEPrivateMediaProofWatchdog(
fingerprint: fingerprint,
sessionGeneration: generation,
timeoutNonce: watchdogNonce
)
stateSendProgress[peerID] =
BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation)
guard var pending = pendingPolicyResolutions[peerID] else {
return (watchdogNonce, [])
}
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else {
pendingPolicyResolutions.removeValue(forKey: peerID)
return (watchdogNonce, Array(pending.completions.values))
}
pending.sessionGeneration = generation
pending.timeoutNonce = watchdogNonce
pendingPolicyResolutions[peerID] = pending
return (watchdogNonce, [])
}
}
/// Records a verified authenticated-peer-state packet for the current
/// generation: pins the observation, retires proof timers, and releases
/// matching policy waiters. Returns nil when the generation is no longer
/// current (the caller's lease raced a replacement).
func applyAuthenticatedPeerState(
for peerID: PeerID,
fingerprint: String,
generation: UUID,
capabilities: PeerCapabilities
) -> [@MainActor (PrivateMediaSendPolicy) -> Void]? {
lock.withLock {
guard sessionGenerations[peerID] == generation else { return nil }
authenticatedStates[peerID] = BLEAuthenticatedPeerStateObservation(
fingerprint: fingerprint,
sessionGeneration: generation,
capabilities: capabilities
)
proofTimeoutMarkers.removeValue(forKey: peerID)
proofWatchdogs.removeValue(forKey: peerID)
guard let pending = pendingPolicyResolutions.removeValue(forKey: peerID),
pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
pending.sessionGeneration == generation else {
return []
}
return Array(pending.completions.values)
}
}
/// Consumes one peer-state send slot (initial or echo) for the current
/// generation. Returns whether the packet should actually go out.
func markPeerStateSend(for peerID: PeerID, echo: Bool) -> Bool {
lock.withLock {
guard let generation = sessionGenerations[peerID],
var progress = stateSendProgress[peerID],
progress.sessionGeneration == generation else { return false }
if echo {
guard !progress.sentEcho else { return false }
progress.sentEcho = true
} else {
guard !progress.sentInitial else { return false }
progress.sentInitial = true
}
stateSendProgress[peerID] = progress
return true
}
}
// MARK: Outbound convergence deferral
func setOutboundDeferredUntilConvergence(_ peerID: PeerID) {
lock.withLock { _ = outboundConvergenceDeferred.insert(peerID) }
}
func clearOutboundDeferredUntilConvergence(_ peerID: PeerID) {
lock.withLock { _ = outboundConvergenceDeferred.remove(peerID) }
}
// MARK: Proof timeout
/// Expires a proof deadline if its nonce/generation/fingerprint still
/// identify the live watchdog or waiter set. On expiry the timeout
/// marker is pinned and any waiters are returned for completion.
/// `deferredOutbound` reports whether the peer's parked queues must
/// stay parked (timeout-restore pending its convergence retry).
func expireProofDeadline(
for peerID: PeerID,
fingerprint: String,
sessionGeneration: UUID?,
nonce: UUID
) -> (expired: Bool, deferredOutbound: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) {
lock.withLock {
let pending = pendingPolicyResolutions[peerID]
let pendingMatches = pending?.timeoutNonce == nonce
&& pending?.sessionGeneration == sessionGeneration
&& pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
let watchdog = proofWatchdogs[peerID]
let watchdogMatches = sessionGeneration != nil
&& watchdog?.timeoutNonce == nonce
&& watchdog?.sessionGeneration == sessionGeneration
&& watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame
guard pendingMatches || watchdogMatches else {
return (false, false, [])
}
var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = []
if pendingMatches, let pending {
completions = Array(pending.completions.values)
}
if pendingMatches {
pendingPolicyResolutions.removeValue(forKey: peerID)
}
if watchdogMatches {
proofWatchdogs.removeValue(forKey: peerID)
}
proofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker(
fingerprint: fingerprint,
sessionGeneration: sessionGeneration
)
return (true, outboundConvergenceDeferred.contains(peerID), completions)
}
}
/// Registers a policy-resolution waiter for a peer still awaiting its
/// capability proof. Joins the existing waiter set when fingerprints
/// match (bounded), otherwise starts one, reusing the live watchdog's
/// deadline identity when it covers the same fingerprint/generation so
/// only one timeout is ever in flight. `shouldSchedule` tells the
/// caller to arm a fresh deadline.
func registerPolicyResolution(
for peerID: PeerID,
fingerprint: String,
requestID: UUID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) {
lock.withLock {
let generation = sessionGenerations[peerID]
if var pending = pendingPolicyResolutions[peerID] {
guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame,
pending.completions.count
< TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else {
return (false, false, UUID(), generation)
}
pending.completions[requestID] = completion
pendingPolicyResolutions[peerID] = pending
return (true, false, pending.timeoutNonce, pending.sessionGeneration)
}
guard pendingPolicyResolutions.count
< TransportConfig.privateMediaCapabilityProofPendingPeerCap else {
return (false, false, UUID(), generation)
}
let currentWatchdog = proofWatchdogs[peerID]
let reusesWatchdog = currentWatchdog?.fingerprint
.caseInsensitiveCompare(fingerprint) == .orderedSame
&& currentWatchdog?.sessionGeneration == generation
let nonce: UUID
if reusesWatchdog, let currentWatchdog {
nonce = currentWatchdog.timeoutNonce
} else {
nonce = UUID()
}
pendingPolicyResolutions[peerID] =
BLEPendingPrivateMediaPolicyResolution(
fingerprint: fingerprint,
sessionGeneration: generation,
timeoutNonce: nonce,
completions: [requestID: completion]
)
return (true, !reusesWatchdog, nonce, generation)
}
}
// MARK: Teardown
/// A session clear retires every generation-bound record. Waiters are
/// kept but rebased onto a nil generation with a fresh deadline nonce,
/// returned so the caller re-arms their timeout.
func clearSession(for peerID: PeerID) -> (fingerprint: String, nonce: UUID)? {
lock.withLock {
sessionGenerations.removeValue(forKey: peerID)
authenticatedStates.removeValue(forKey: peerID)
proofTimeoutMarkers.removeValue(forKey: peerID)
proofWatchdogs.removeValue(forKey: peerID)
stateSendProgress.removeValue(forKey: peerID)
outboundConvergenceDeferred.remove(peerID)
guard var pending = pendingPolicyResolutions[peerID] else {
return nil
}
let nonce = UUID()
pending.sessionGeneration = nil
pending.timeoutNonce = nonce
pendingPolicyResolutions[peerID] = pending
return (pending.fingerprint, nonce)
}
}
/// Panic wipe: these records belong to pre-panic transfer state, and
/// invoking their callbacks would let queued UI work recreate or resend
/// wiped media drop everything.
func panicReset() {
lock.withLock {
sessionGenerations.removeAll()
authenticatedStates.removeAll()
proofTimeoutMarkers.removeAll()
proofWatchdogs.removeAll()
pendingPolicyResolutions.removeAll()
stateSendProgress.removeAll()
outboundConvergenceDeferred.removeAll()
}
}
}
extension BLEPrivateMediaSessionStore {
/// The current generation iff its authenticated peer state proved the
/// private-media capability (and, when required, durable receipts).
func provenGeneration(for peerID: PeerID, requireReceipts: Bool) -> UUID? {
let inputs = policyInputs(for: peerID)
guard let generation = inputs.sessionGeneration,
let authenticated = inputs.authenticatedState,
authenticated.sessionGeneration == generation,
authenticated.capabilities.contains(.privateMedia) else { return nil }
if requireReceipts {
guard authenticated.capabilities.contains(.privateMediaReceipts) else { return nil }
}
return generation
}
}

View File

@ -77,6 +77,26 @@ struct BLEReceivePipeline {
}
}
/// Lock-backed traffic-level signal: the receive pipeline records packets,
/// and the radio layer (maintenance and scan-duty adaptation on bleQueue)
/// reads the level without crossing onto a transport queue.
final class BLERecentTrafficMonitor: @unchecked Sendable {
private let lock = NSLock()
private var tracker = BLERecentTrafficTracker()
func recordPacket(at now: Date) {
lock.withLock { tracker.recordPacket(at: now) }
}
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
lock.withLock { tracker.hasTraffic(within: seconds, now: now) }
}
func removeAll() {
lock.withLock { tracker.removeAll() }
}
}
struct BLERecentTrafficTracker: Equatable {
private var packetTimestamps: [Date] = []

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,8 @@ final class BoardManager: ObservableObject {
@Published private(set) var posts: [BoardPostPacket] = []
private let transport: Transport
/// Board broadcast rides the mesh only; absent on other transports.
private var boardTransport: MeshBoardBroadcasting? { transport as? MeshBoardBroadcasting }
/// Publishes a bridged kind-1 note (expiring with the board post via
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
/// was skipped.
@ -122,7 +124,7 @@ final class BoardManager: ObservableObject {
flags: flags,
signature: signature
)
transport.sendBoardPayload(BoardWire.post(post).encode())
boardTransport?.sendBoardPayload(BoardWire.post(post).encode())
// Nostr bridge: geohash posts also go out as kind-1 location notes so
// online users see them. Remember the event id for merged deletes.
@ -148,7 +150,7 @@ final class BoardManager: ObservableObject {
deletedAt: deletedAt,
signature: signature
)
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
boardTransport?.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
// Merged delete: also retract the bridged Nostr copy when we still
// know its event id.

View File

@ -90,6 +90,9 @@ protocol CommandContextProvider: AnyObject {
final class CommandProcessor {
weak var contextProvider: CommandContextProvider?
weak var meshService: Transport?
/// Mesh-only command surfaces, absent when the transport lacks them.
private var meshDiagnostics: MeshDiagnosing? { meshService as? MeshDiagnosing }
private var meshArchive: MeshPublicArchiving? { meshService as? MeshPublicArchiving }
private let identityManager: SecureIdentityStateManagerProtocol
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
@ -371,7 +374,7 @@ final class CommandProcessor {
}
// Scrub their carried public messages now, while the peerID is
// resolvable, so they can't resurface as archived echoes.
meshService?.purgeArchivedPublicMessages(from: peerID)
meshArchive?.purgeArchivedPublicMessages(from: peerID)
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
}
// Mesh lookup failed; try geohash (Nostr) participant by display name
@ -474,7 +477,7 @@ final class CommandProcessor {
// meshPingTimeoutSeconds later, and reading the selected chat at
// callback time would misroute the result after a chat switch.
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
meshDiagnostics?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
let provider = currentProvider
guard let result else {
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
@ -496,7 +499,7 @@ final class CommandProcessor {
}
guard let mesh = meshService,
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
let intermediates = meshDiagnostics?.computeMeshPath(to: target.peerID) else {
return .success(message: "no known path to \(target.nickname)")
}
// Graph-derived from gossiped neighbor claims, not route-recorded

View File

@ -0,0 +1,152 @@
import BitFoundation
import CoreBluetooth
import Foundation
/// Optional transport capabilities, discovered with `as?` instead of casting
/// to a concrete transport class. `Transport` stays the contract every
/// transport genuinely implements; a capability protocol here is the
/// contract for one mesh-only feature surface, so app wiring depends on the
/// feature it needs rather than on `BLEService` itself.
/// Radio-state reporting for transports backed by a local radio.
protocol BluetoothStateReporting: AnyObject {
func getCurrentBluetoothState() -> CBManagerState
}
/// Panic-mode lifecycle for transports that own durable identity state.
/// A transport implementing this owns its own restart sequencing:
/// `completePanicReset` decides whether services come back, so generic
/// `startServices()` calls after a panic belong only to transports that
/// don't implement it.
protocol PanicResettingTransport: AnyObject {
/// Quiesces the radio and drains in-flight work ahead of a panic wipe.
func suspendForPanicReset()
/// Finishes a panic wipe, optionally restarting services.
func completePanicReset(restartServices: Bool)
/// Rotates the transport identity as part of a panic reset.
func resetIdentityForPanic(currentNickname: String, restartServices: Bool)
}
/// File and private-media transfer over a mesh transport, including the
/// capability-proof policy that gates encrypted private media.
protocol MeshFileTransferring: AnyObject {
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
/// Automatic whole-file retry is admitted only while this exact Noise
/// generation authenticates bit 9. It must never queue across a session
/// replacement or enter the signed raw legacy path.
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
)
func cancelTransfer(_ transferId: String)
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// The exact current Noise generation that authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID?
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
}
/// Live voice / push-to-talk: one encoded `VoiceBurstPacket`,
/// fire-and-forget inside the Noise session (private) or as a signed
/// ephemeral broadcast (public). Frames are only useful now the
/// transport drops them (never queues) without an established session.
protocol MeshVoiceStreaming: AnyObject {
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID)
func sendVoiceFrameBroadcast(_ burstContent: Data)
}
/// Courier store-and-forward: seal a message to the recipient's static
/// key and hand it to connected couriers for physical delivery while the
/// recipient is offline. Returns false when the transport cannot courier.
protocol MeshCourierTransporting: AnyObject {
@discardableResult
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
}
/// Private groups: creator-signed state travels 1:1 over Noise sessions;
/// group messages flood like public broadcasts.
protocol MeshGroupMessaging: AnyObject {
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
func broadcastGroupMessage(_ envelope: Data)
}
/// Bulletin board: broadcast a pre-signed board payload (post or
/// tombstone) so it spreads over relay and gossip sync.
protocol MeshBoardBroadcasting: AnyObject {
func sendBoardPayload(_ payload: Data)
}
/// Mesh diagnostics (/ping, /trace, topology map).
protocol MeshDiagnosing: AnyObject {
/// Sends a directed ping probe; the completion fires exactly once on
/// the main actor with the measured result, or nil on timeout.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
/// Estimated intermediate hops toward `peerID` from gossiped topology
/// ([] = direct link, nil = no known path).
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
/// Current mesh graph for the topology map.
func currentMeshTopology() -> MeshTopologySnapshot?
}
/// QR verification and transitive vouching over the Noise session.
protocol MeshVerifying: AnyObject {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
}
/// Store-and-forward archive: the public messages this device is carrying
/// for gossip sync, decoded for display as "heard here earlier" echoes.
protocol MeshPublicArchiving: AnyObject {
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void)
/// Drops any carried public messages from a (newly blocked) sender so
/// they can't resurface as archived echoes on a later launch.
func purgeArchivedPublicMessages(from peerID: PeerID)
/// Erases the whole carried public-message archive, on disk included.
func purgeAllArchivedPublicMessages()
}
/// Internet-gateway and geohash-bridge wiring surface (BLE mesh today).
/// Everything the gateway/bridge/courier services need from the mesh
/// transport, so their bootstrap wiring never touches the concrete class.
protocol MeshBridgingTransport: AnyObject {
// Runtime-advertised capability bits
func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool)
func setLocalBridgeGeohash(_ cell: String?)
func advertisedBridgeGeohash() -> String?
// Peers currently advertising bridging roles
func reachableGatewayPeers() -> [PeerID]
func reachableBridgePeers() -> [PeerID]
// Gateway carrier packets (mesh <-> Nostr uplink/downlink)
@discardableResult
func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool
func broadcastNostrCarrier(_ payload: Data)
/// Sink for received carrier packets (set once by app wiring; called on
/// the main actor after transport-level checks).
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? { get set }
// Bridge courier drops (sealed envelopes carried across the bridge)
func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope?
@discardableResult
func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool
@discardableResult
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool
func myNoiseStaticPublicKey() -> Data
func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)]
/// Fired (off-main) when a signature-verified announce is processed.
var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? { get set }
}

View File

@ -281,6 +281,7 @@ final class MessageRouter {
guard remainingSlots > 0 else { return }
for transport in transports {
guard let courierTransport = transport as? MeshCourierTransporting else { continue }
let couriers = eligibleCouriers(
on: transport,
recipientKey: recipientKey,
@ -288,7 +289,7 @@ final class MessageRouter {
limit: remainingSlots
)
guard !couriers.isEmpty else { continue }
if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
if courierTransport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
onMessageCarried?(messageID, peerID)
@ -304,6 +305,7 @@ final class MessageRouter {
/// `maxCouriersPerMessage` distinct couriers or expires.
func courierBecameAvailable(_ peerID: PeerID) {
for transport in transports {
guard let courierTransport = transport as? MeshCourierTransporting else { continue }
guard transport.isPeerConnected(peerID),
let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
let courierKey = snapshot.noisePublicKey,
@ -319,7 +321,7 @@ final class MessageRouter {
guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
!message.depositedCourierKeys.contains(courierKey),
currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
if courierTransport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
onMessageCarried?(message.messageID, recipient)

View File

@ -203,99 +203,14 @@ protocol Transport: AnyObject {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
)
/// Automatic whole-file retry is admitted only while this exact Noise
/// generation authenticates bit 9. It must never queue across a session
/// replacement or enter the signed raw legacy path.
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
)
func cancelTransfer(_ transferId: String)
// Live voice / push-to-talk (mesh transports only): one encoded
// `VoiceBurstPacket`, fire-and-forget inside the Noise session. Frames are
// only useful now transports drop them (never queue) when no
// established session exists.
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID)
// Public-mesh counterpart: signed ephemeral broadcast, never synced.
func sendVoiceFrameBroadcast(_ burstContent: Data)
// Courier store-and-forward (mesh transports only): seal a message to the
// recipient's static key and hand it to connected couriers for physical
// delivery while the recipient is offline. Returns false when the
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// Private groups (mesh transports only): creator-signed state travels
// 1:1 over Noise sessions; group messages flood like public broadcasts.
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
func broadcastGroupMessage(_ envelope: Data)
// Bulletin board (mesh transports only): broadcast a pre-signed board
// payload (post or tombstone) so it spreads over relay and gossip sync.
func sendBoardPayload(_ payload: Data)
// Mesh diagnostics (optional for transports). Defaults are inert so
// queue-backed transports (e.g. NostrTransport) stay untouched.
/// Sends a directed ping probe; the completion fires exactly once on the
/// main actor with the measured result, or nil on timeout/unsupported.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
/// Estimated intermediate hops toward `peerID` from gossiped topology
/// ([] = direct link, nil = no known path).
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
/// Current mesh graph for the topology map; nil when unsupported.
func currentMeshTopology() -> MeshTopologySnapshot?
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// Vouching / transitive verification (optional for transports)
/// Capabilities the peer advertised in its last verified announce;
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// The exact current Noise generation that authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID?
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
)
/// Sends an encoded vouch-attestation batch inside the Noise session.
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
/// Appends a peer-authenticated observer. Unlike
/// `installNoiseSessionCallbacks` this never touches the (single-slot)
/// handshake-required callback, so secondary features can observe
/// session establishment without disturbing the primary registration.
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
// Store-and-forward archive (mesh transports only): the public messages
// this device is carrying for gossip sync, decoded for display as
// "heard here earlier" timeline echoes.
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void)
/// Drops any carried public messages from a (newly blocked) sender so
/// they can't resurface as archived echoes on a later launch.
func purgeArchivedPublicMessages(from peerID: PeerID)
/// Erases the whole carried public-message archive, on disk included, so
/// clearing the mesh timeline deletes that history rather than hiding it.
func purgeAllArchivedPublicMessages()
}
/// A carried public mesh message from the store-and-forward window, decoded
@ -341,72 +256,12 @@ extension Transport {
onHandshakeRequired: @escaping (PeerID) -> Void
) {}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
nil
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
let policy = privateMediaSendPolicy(to: peerID)
Task { @MainActor in
completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy)
}
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendBoardPayload(_ payload: Data) {}
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {}
func sendVoiceFrameBroadcast(_ burstContent: Data) {}
// Mesh diagnostics are mesh-transport-only; other transports report
// "no reply"/"no path" rather than pretending to measure anything.
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
Task { @MainActor in completion(nil) }
}
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
guard !allowLegacyFallback else { return }
sendFilePrivate(packet, to: peerID, transferId: transferId)
}
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions)
}
func acceptPendingFile(id: String) -> URL? { nil }
func declinePendingFile(id: String) {}
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
Task { @MainActor in completion([]) }
}
func purgeArchivedPublicMessages(from peerID: PeerID) {}
func purgeAllArchivedPublicMessages() {}
}
protocol TransportPeerEventsDelegate: AnyObject {
@ -450,3 +305,14 @@ extension BitchatDelegate {
}
extension BLEService: Transport {}
extension BLEService: MeshFileTransferring {}
extension BLEService: MeshVoiceStreaming {}
extension BLEService: MeshCourierTransporting {}
extension BLEService: MeshGroupMessaging {}
extension BLEService: MeshBoardBroadcasting {}
extension BLEService: MeshDiagnosing {}
extension BLEService: MeshVerifying {}
extension BLEService: MeshPublicArchiving {}
extension BLEService: BluetoothStateReporting {}
extension BLEService: PanicResettingTransport {}
extension BLEService: MeshBridgingTransport {}

View File

@ -279,7 +279,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Purge while the fingerprintpeerID mapping is still known: the
// archived-echo seed filter can't resolve offline strangers, so
// scrub their carried messages now rather than at relaunch.
meshService.purgeArchivedPublicMessages(from: peerID)
(meshService as? MeshPublicArchiving)?.purgeArchivedPublicMessages(from: peerID)
}
updatePeers()
return fingerprint

View File

@ -105,16 +105,19 @@ extension ChatViewModel: ChatGroupContext {
identityManager.isBlocked(fingerprint: fingerprint)
}
/// Group state rides the mesh's Noise sessions only.
private var groupTransport: MeshGroupMessaging? { meshService as? MeshGroupMessaging }
func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) {
meshService.sendGroupInvite(payload, to: peerID)
groupTransport?.sendGroupInvite(payload, to: peerID)
}
func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) {
meshService.sendGroupKeyUpdate(payload, to: peerID)
groupTransport?.sendGroupKeyUpdate(payload, to: peerID)
}
func broadcastGroupMessagePayload(_ payload: Data) {
meshService.broadcastGroupMessage(payload)
groupTransport?.broadcastGroupMessage(payload)
}
// MARK: CommandContextProvider group commands (parsed by CommandProcessor)

View File

@ -106,8 +106,8 @@ extension ChatViewModel: ChatLifecycleContext {
}
func refreshBluetoothState() {
if let bleService = meshService as? BLEService {
updateBluetoothState(bleService.getCurrentBluetoothState())
if let radio = meshService as? BluetoothStateReporting {
updateBluetoothState(radio.getCurrentBluetoothState())
}
}

View File

@ -151,14 +151,19 @@ extension ChatViewModel: ChatMediaTransferContext {
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten mesh service accesses.
/// File transfer rides the mesh only. Without that capability the
/// policy degrades to the safe floor (blocked), matching the old
/// inert protocol defaults.
private var fileTransport: MeshFileTransferring? { meshService as? MeshFileTransferring }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
meshService.privateMediaSendPolicy(to: peerID)
fileTransport?.privateMediaSendPolicy(to: peerID) ?? .blockedDowngrade
}
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
meshService.authenticatedPrivateMediaReceiptSessionGeneration(
fileTransport?.authenticatedPrivateMediaReceiptSessionGeneration(
to: peerID
)
}
@ -167,7 +172,11 @@ extension ChatViewModel: ChatMediaTransferContext {
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
guard let fileTransport else {
Task { @MainActor in completion(.blockedDowngrade) }
return
}
fileTransport.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
}
func requestLegacyPrivateMediaConsent(
@ -197,7 +206,7 @@ extension ChatViewModel: ChatMediaTransferContext {
transferId: String,
allowLegacyFallback: Bool
) {
meshService.sendFilePrivate(
fileTransport?.sendFilePrivate(
packet,
to: peerID,
transferId: transferId,
@ -210,7 +219,7 @@ extension ChatViewModel: ChatMediaTransferContext {
to peerID: PeerID,
transferId: String
) {
meshService.sendFilePrivateReceiptRetry(
fileTransport?.sendFilePrivateReceiptRetry(
packet,
to: peerID,
transferId: transferId
@ -218,11 +227,11 @@ extension ChatViewModel: ChatMediaTransferContext {
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
meshService.sendFileBroadcast(packet, transferId: transferId)
fileTransport?.sendFileBroadcast(packet, transferId: transferId)
}
func cancelTransfer(_ transferId: String) {
meshService.cancelTransfer(transferId)
fileTransport?.cancelTransfer(transferId)
}
func removeUntombstonedMediaMessage(withID messageID: String) {

View File

@ -129,12 +129,15 @@ extension ChatViewModel: ChatVerificationContext {
messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
}
/// QR verification rides the mesh's Noise sessions only.
private var verifyTransport: MeshVerifying? { meshService as? MeshVerifying }
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
verifyTransport?.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
verifyTransport?.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func postLocalNotification(title: String, body: String, identifier: String) {

View File

@ -1069,7 +1069,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
}
func purgeArchivedPublicMessages() {
meshService.purgeAllArchivedPublicMessages()
(meshService as? MeshPublicArchiving)?.purgeAllArchivedPublicMessages()
}
/// Queues a system message for the next geohash channel visit. (Tiny
@ -1564,8 +1564,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// Quiesce the mesh before clearing stores. Identity replacement below
// deliberately stays stopped until media deletion and marker commit.
if let bleService = meshService as? BLEService {
bleService.suspendForPanicReset()
if let panicTransport = meshService as? PanicResettingTransport {
panicTransport.suspendForPanicReset()
} else {
meshService.emergencyDisconnectAll()
}
@ -1700,8 +1700,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// Replace the BLE identity while keeping the radio stopped. It may
// reopen only after the durable panic transaction commits.
if let bleService = meshService as? BLEService {
bleService.resetIdentityForPanic(
if let panicTransport = meshService as? PanicResettingTransport {
panicTransport.resetIdentityForPanic(
currentNickname: nickname,
restartServices: false
)
@ -1746,18 +1746,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
guard panicCompleted else { return false }
if let bleService = meshService as? BLEService {
if let panicTransport = meshService as? PanicResettingTransport {
// Startup recovery reopens admission but leaves actual service
// start to the bootstrapper immediately after this method.
bleService.completePanicReset(
panicTransport.completePanicReset(
restartServices: restartServices
)
}
if restartServices {
// All persistent state and media are gone. Bring each service back
// only now, under the new identity.
if !(meshService is BLEService) {
// only now, under the new identity a panic-resetting transport
// owns its own restart sequencing above.
if !(meshService is PanicResettingTransport) {
meshService.startServices()
}

View File

@ -195,9 +195,8 @@ private extension ChatViewModelBootstrapper {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
guard let viewModel,
let bleService = viewModel.meshService as? BLEService else { return }
let state = bleService.getCurrentBluetoothState()
viewModel.updateBluetoothState(state)
let radio = viewModel.meshService as? BluetoothStateReporting else { return }
viewModel.updateBluetoothState(radio.getCurrentBluetoothState())
}
viewModel.nostrRelayManager = NostrRelayManager.shared
@ -219,8 +218,9 @@ private extension ChatViewModelBootstrapper {
/// right after transport start, so give it a beat before asking.
private func loadArchivedEchoes() {
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in
guard let viewModel else { return }
viewModel.meshService.collectArchivedPublicMessages { [weak viewModel] allArchived in
guard let viewModel,
let archive = viewModel.meshService as? MeshPublicArchiving else { return }
archive.collectArchivedPublicMessages { [weak viewModel] allArchived in
guard let viewModel else { return }
// A previous /clear dismissed everything heard up to its
// watermark; only newer archive entries come back. Blocking a
@ -331,7 +331,7 @@ private extension ChatViewModelBootstrapper {
func configureGateway() {
// Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
// has no carrier packets to bridge.
guard let bleService = viewModel.meshService as? BLEService else { return }
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
let gateway = GatewayService.shared
gateway.publishToRelays = { event, geohash in
@ -410,7 +410,7 @@ private extension ChatViewModelBootstrapper {
/// transport, the relay manager, location, and the public timeline. Same
/// closure-injection style as `configureGateway`.
func configureBridge() {
guard let bleService = viewModel.meshService as? BLEService else { return }
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
let bridge = BridgeService.shared
let idBridge = viewModel.idBridge
@ -545,7 +545,7 @@ private extension ChatViewModelBootstrapper {
/// manager, the mesh transport's sealing/opening primitives, the courier
/// store, and the message router's deposit path.
func configureBridgeCourier() {
guard let bleService = viewModel.meshService as? BLEService else { return }
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
let courier = BridgeCourierService.shared
courier.bridgeEnabled = { BridgeService.shared.isEnabled }

View File

@ -90,7 +90,7 @@ extension ChatViewModel: ChatVouchContext {
}
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
meshService.sendVouchAttestations(payload, to: peerID)
(meshService as? MeshVerifying)?.sendVouchAttestations(payload, to: peerID)
}
func notifyPeerTrustChanged() {

View File

@ -97,14 +97,17 @@ extension ChatViewModel {
/// `sendVoiceNote(at:)`, which live receivers absorb into the live bubble.
@MainActor
func makeVoiceCaptureSession() -> VoiceCaptureSession {
// Live voice rides the mesh only; frames are useful now or never,
// so a transport without the capability just drops them.
let voiceTransport = meshService as? MeshVoiceStreaming
switch liveVoiceTarget() {
case .peer(let peerID):
return PTTLiveVoiceSession(sendPacket: { [meshService] packet in
meshService.sendVoiceFrame(packet, to: peerID)
return PTTLiveVoiceSession(sendPacket: { packet in
voiceTransport?.sendVoiceFrame(packet, to: peerID)
})
case .publicMesh:
return PTTLiveVoiceSession(sendPacket: { [meshService] packet in
meshService.sendVoiceFrameBroadcast(packet)
return PTTLiveVoiceSession(sendPacket: { packet in
voiceTransport?.sendVoiceFrameBroadcast(packet)
})
case nil:
SecureLogger.info("PTT: hold uses classic voice note (liveVoiceEnabled=\(PTTSettings.liveVoiceEnabled), dmSelected=\(selectedPrivateChatPeer != nil))", category: .session)

View File

@ -13,6 +13,58 @@ import BitFoundation
struct BLEServiceCoreTests {
/// Records ping completions (delivered on the main actor) so the
/// injected-clock test can assert from its own thread.
private final class MeshPingResultCollector: @unchecked Sendable {
private let lock = NSLock()
private var recorded: [MeshPingResult?] = []
var results: [MeshPingResult?] { lock.withLock { recorded } }
func record(_ result: MeshPingResult?) {
lock.withLock { recorded.append(result) }
}
}
/// The ping deadline asserted on an injected clock: the real 10s
/// product constant, no wall-clock in the loop. This is the pattern
/// for every engine deadline the timeout must not fire early, must
/// fire exactly once at the deadline, and must stay consumed after.
@Test
func meshPingTimesOutOnTheInjectedClockExactlyOnce() async throws {
let scheduler = BLEEngineManualScheduler()
let ble = makeService(engineScheduler: scheduler)
let peer = PeerID(str: "aabbccdd00112233")
ble._test_seedConnectedPeer(peer, nickname: "Alice")
let collector = MeshPingResultCollector()
ble.sendMeshPing(to: peer) { result in
collector.record(result)
}
// The probe registers and its deadline schedules on the engine;
// fence that submission before touching the clock.
await ble._test_drainNoiseMessagePipeline()
#expect(scheduler.pendingCount == 1)
// A hair before the deadline nothing may fire.
scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds - 0.01)
await ble._test_drainNoiseMessagePipeline()
#expect(collector.results.isEmpty)
// Crossing the deadline expires the probe: nil, exactly once, on
// the main actor.
scheduler.advance(by: 0.02)
let completed = await TestHelpers.waitUntil(
{ collector.results.count == 1 },
timeout: TestConstants.longTimeout
)
#expect(completed)
#expect(collector.results == [nil])
// The deadline is consumed more time cannot re-fire it.
scheduler.advance(by: TransportConfig.meshPingTimeoutSeconds * 2)
await ble._test_drainNoiseMessagePipeline()
#expect(collector.results.count == 1)
}
@Test
func duplicatePacket_isDeduped() async throws {
let ble = makeService()
@ -908,6 +960,17 @@ struct BLEServiceCoreTests {
// old generation the remote may no longer be able to read.
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
// The capability-proof watchdog armed at the original authentication
// is still live and can genuinely reach its real 5s deadline here on
// a stalled CI runner. Fire it deterministically: its drain must
// respect the deferred-until-convergence state instead of encrypting
// the parked queues under the restored keys (the exact silent loss
// the defer path exists to prevent). The retry below then still
// finds the queues parked.
ble._test_forcePrivateMediaProofTimeout(for: alicePeerID)
await ble._test_drainNoiseMessagePipeline()
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
// Release the mandatory convergence retry: it retires the restored
// session and starts a fresh XX exchange with the live peer.
recoveryGate.release()
@ -1499,7 +1562,8 @@ private final class PanicIngressObserver: @unchecked Sendable {
private func makeService(
noiseResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler()
) -> BLEService {
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
@ -1509,7 +1573,8 @@ private func makeService(
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false,
noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout
noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout,
engineScheduler: engineScheduler
)
}

View File

@ -669,7 +669,7 @@ struct CourierEndToEndTests {
/// Minimal transport stub for exercising MessageRouter's courier deposit
/// logic without BLE plumbing.
private final class CourierCaptureTransport: Transport {
private final class CourierCaptureTransport: Transport, MeshCourierTransporting {
weak var delegate: BitchatDelegate?
weak var eventDelegate: TransportEventDelegate?
weak var peerEventsDelegate: TransportPeerEventsDelegate?

View File

@ -204,7 +204,8 @@ struct PrivateMediaEndToEndTests {
alice.sendFilePrivate(
file,
to: bob.myPeerID,
transferId: deniedID
transferId: deniedID,
allowLegacyFallback: false
)
let denied = await TestHelpers.waitUntil(
{ cancellations.contains(deniedID) },
@ -254,7 +255,7 @@ struct PrivateMediaEndToEndTests {
// Consent is invocation-scoped, not a sticky peer preference.
let retryID = "legacy-retry-without-consent-\(UUID().uuidString)"
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID)
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID, allowLegacyFallback: false)
let retryDenied = await TestHelpers.waitUntil(
{ cancellations.contains(retryID) },
timeout: TestConstants.longTimeout
@ -998,7 +999,7 @@ struct PrivateMediaEndToEndTests {
let encryptedID = "encrypted-over-256-\(UUID().uuidString)"
let legacyID = "legacy-over-256-\(UUID().uuidString)"
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID)
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID, allowLegacyFallback: false)
alice.sendFilePrivate(
file,
to: oldCarol.myPeerID,
@ -1318,7 +1319,7 @@ struct PrivateMediaEndToEndTests {
mimeType: mimeType,
content: content
)
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)")
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)", allowLegacyFallback: false)
let fragmented = await TestHelpers.waitUntil(
{ tap.hasCompleteFragmentTrain },

View File

@ -0,0 +1,49 @@
import Foundation
@testable import bitchat
/// Manually advanced engine scheduler: deferred work runs when the test
/// advances the clock past its deadline, on the real engine queue (deferred
/// bodies touch engine-confined state), and `advance` returns only after
/// the released work has finished so assertions that follow observe its
/// engine-side effects without polling.
final class BLEEngineManualScheduler: BLEEngineScheduling, @unchecked Sendable {
private let lock = NSLock()
private var engineQueue: DispatchQueue?
private var now: TimeInterval = 0
private var pending: [(deadline: TimeInterval, work: DispatchWorkItem)] = []
func activate(engineQueue: DispatchQueue) {
lock.withLock { self.engineQueue = engineQueue }
}
func schedule(after delay: TimeInterval, execute work: DispatchWorkItem) {
lock.withLock { pending.append((now + delay, work)) }
}
var pendingCount: Int {
lock.withLock { pending.count }
}
/// Advances the clock, releasing due work in deadline order.
/// Cancellation keeps its production semantics: dispatch skips a
/// cancelled `DispatchWorkItem` at execution.
func advance(by interval: TimeInterval) {
let (due, queue): ([DispatchWorkItem], DispatchQueue?) = lock.withLock {
now += interval
let cutoff = now
let released = pending
.filter { $0.deadline <= cutoff }
.sorted { $0.deadline < $1.deadline }
.map(\.work)
pending.removeAll { $0.deadline <= cutoff }
return (released, engineQueue)
}
guard let queue else { return }
for work in due {
queue.async(execute: work)
}
// Fence: released work (and anything it enqueued) has run before
// the test's next assertion.
queue.sync {}
}
}

View File

@ -14,7 +14,10 @@ import BitFoundation
/// Mock Transport implementation for testing ChatViewModel in isolation.
/// Records all method calls and allows test code to verify interactions.
final class MockTransport: Transport, PrivateMediaDeletionPersisting {
final class MockTransport: Transport, PrivateMediaDeletionPersisting,
MeshFileTransferring, MeshVerifying, MeshCourierTransporting,
MeshDiagnosing, MeshPublicArchiving, MeshVoiceStreaming,
MeshGroupMessaging, MeshBoardBroadcasting {
// MARK: - Protocol Properties
@ -205,11 +208,6 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
sentBroadcastFiles.append((packet, transferId))
}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
sentPrivateFiles.append((packet, peerID, transferId))
sentPrivateFileLegacyAllowances.append(false)
}
func sendFilePrivate(
_ packet: BitchatFilePacket,
to peerID: PeerID,
@ -242,6 +240,15 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
cancelledTransfers.append(transferId)
}
private(set) var sentFileReceiptRetries: [(BitchatFilePacket, PeerID, String)] = []
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {
sentFileReceiptRetries.append((packet, peerID, transferId))
}
@MainActor
func persistDeletedPrivateMedia(
messageIDs: [String],
@ -317,6 +324,50 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
meshTopologySnapshot
}
// MARK: - Remaining mesh capabilities (recording stubs)
private(set) var sentVouchAttestations: [(Data, PeerID)] = []
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
sentVouchAttestations.append((payload, peerID))
}
var archivedPublicMessages: [ArchivedPublicMessage] = []
private(set) var purgedAllArchived = false
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
let archived = archivedPublicMessages
Task { @MainActor in completion(archived) }
}
func purgeAllArchivedPublicMessages() {
purgedAllArchived = true
}
private(set) var sentVoiceFrames: [(Data, PeerID)] = []
private(set) var sentVoiceBroadcasts: [Data] = []
func sendVoiceFrame(_ burstContent: Data, to peerID: PeerID) {
sentVoiceFrames.append((burstContent, peerID))
}
func sendVoiceFrameBroadcast(_ burstContent: Data) {
sentVoiceBroadcasts.append(burstContent)
}
private(set) var sentGroupInvites: [(Data, PeerID)] = []
private(set) var sentGroupKeyUpdates: [(Data, PeerID)] = []
private(set) var broadcastGroupMessages: [Data] = []
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {
sentGroupInvites.append((statePayload, peerID))
}
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {
sentGroupKeyUpdates.append((statePayload, peerID))
}
func broadcastGroupMessage(_ envelope: Data) {
broadcastGroupMessages.append(envelope)
}
private(set) var sentBoardPayloads: [Data] = []
func sendBoardPayload(_ payload: Data) {
sentBoardPayloads.append(payload)
}
// MARK: - Test Helpers
/// Clears all recorded method calls for fresh assertions

View File

@ -85,24 +85,16 @@ struct ProtocolContractTests {
func transportDefaults_forwardOrNoOp() {
let probe = DefaultTransportProbe()
let peerID = PeerID(str: "0123456789abcdef")
let filePacket = BitchatFilePacket(
fileName: "voice.m4a",
fileSize: 4,
mimeType: "audio/mp4",
content: Data([1, 2, 3, 4])
)
probe.sendMessage("hello", mentions: ["@alice"], messageID: "msg-1", timestamp: Date())
probe.sendVerifyChallenge(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x01]))
probe.sendVerifyResponse(to: peerID, noiseKeyHex: "abcd", nonceA: Data([0x02]))
probe.sendFileBroadcast(filePacket, transferId: "tx-1")
probe.sendFilePrivate(filePacket, to: peerID, transferId: "tx-2")
probe.cancelTransfer("tx-3")
probe.declinePendingFile(id: "pending")
#expect(probe.sentMessages.count == 1)
#expect(probe.sentMessages.first?.content == "hello")
#expect(probe.acceptPendingFile(id: "pending") == nil)
// Mesh-only features are capability protocols now, not inert
// defaults: a core-only transport simply doesn't have them.
#expect(!(probe as AnyObject is MeshFileTransferring))
#expect(!(probe as AnyObject is MeshDiagnosing))
#expect(probe.peerCapabilities(peerID).isEmpty)
// Secure delivery defaults to prompt delivery (itself defaulting to
// reachability) for transports without a forgeable link layer.
#expect(probe.canDeliverSecurely(to: peerID) == false)

View File

@ -0,0 +1,84 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct BLEMeshPingTrackerTests {
private func makeProbe(peerID: PeerID) -> BLEMeshPingProbe {
BLEMeshPingProbe(
peerID: peerID,
sentAt: Date(timeIntervalSince1970: 1_000),
lifecycleGeneration: 1,
completion: { _ in },
timeout: DispatchWorkItem {}
)
}
@Test func resolveReturnsProbeOnlyForTheProbedPeer() {
var tracker = BLEMeshPingTracker()
let nonce = Data([1, 2, 3, 4, 5, 6, 7, 8])
let probed = PeerID(str: "aaaaaaaaaaaaaaaa")
tracker.register(makeProbe(peerID: probed), nonce: nonce)
// A pong claiming the right nonce from the wrong peer must not
// consume the probe.
let wrongPeer = tracker.resolve(nonce: nonce, from: PeerID(str: "bbbbbbbbbbbbbbbb"))
#expect(wrongPeer == nil)
let rightPeer = tracker.resolve(nonce: nonce, from: probed)
#expect(rightPeer != nil)
// Consumed exactly once.
let secondResolve = tracker.resolve(nonce: nonce, from: probed)
#expect(secondResolve == nil)
}
@Test func expireConsumesTheProbeSoResolveCannotFireTwice() {
var tracker = BLEMeshPingTracker()
let nonce = Data([9, 9, 9, 9, 9, 9, 9, 9])
let probed = PeerID(str: "aaaaaaaaaaaaaaaa")
tracker.register(makeProbe(peerID: probed), nonce: nonce)
let firstExpire = tracker.expire(nonce: nonce)
#expect(firstExpire != nil)
let secondExpire = tracker.expire(nonce: nonce)
#expect(secondExpire == nil)
let resolveAfterExpire = tracker.resolve(nonce: nonce, from: probed)
#expect(resolveAfterExpire == nil)
}
@Test func inboundBudgetIsPerLinkAndBounded() {
var tracker = BLEMeshPingTracker()
let now = Date(timeIntervalSince1970: 2_000)
let linkA = PeerID(str: "aaaaaaaaaaaaaaaa")
let linkB = PeerID(str: "bbbbbbbbbbbbbbbb")
var allowedOnA = 0
for _ in 0..<(TransportConfig.meshPingInboundMaxPerLink + 5) {
if tracker.shouldRespond(toLink: linkA, now: now) { allowedOnA += 1 }
}
#expect(allowedOnA == TransportConfig.meshPingInboundMaxPerLink)
// One saturated link must not consume another link's budget.
let allowedOnB = tracker.shouldRespond(toLink: linkB, now: now)
#expect(allowedOnB)
}
@Test func resetDropsProbesRestoresBudgetAndHandsBackTimeouts() {
var tracker = BLEMeshPingTracker()
let now = Date(timeIntervalSince1970: 3_000)
let link = PeerID(str: "aaaaaaaaaaaaaaaa")
let nonce = Data([4, 4, 4, 4, 4, 4, 4, 4])
tracker.register(makeProbe(peerID: link), nonce: nonce)
for _ in 0..<TransportConfig.meshPingInboundMaxPerLink {
_ = tracker.shouldRespond(toLink: link, now: now)
}
let saturated = tracker.shouldRespond(toLink: link, now: now)
#expect(!saturated)
let timeouts = tracker.reset()
#expect(timeouts.count == 1)
let resolveAfterReset = tracker.resolve(nonce: nonce, from: link)
#expect(resolveAfterReset == nil)
let allowedAfterReset = tracker.shouldRespond(toLink: link, now: now)
#expect(allowedAfterReset)
}
}

View File

@ -0,0 +1,192 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct BLEPrivateMediaSessionStoreTests {
private let peer = PeerID(str: "aaaaaaaaaaaaaaaa")
private let fingerprint = "ABCDEF0123456789"
@Test func sameGenerationReconciliationDoesNotRearmProofMachinery() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
let fresh = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
#expect(fresh != nil)
// A quarantine-restore of the same generation is a reconciliation,
// not a new session: no new watchdog, no waiter churn.
let again = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
#expect(again == nil)
// The original watchdog identity survives.
#expect(store.proofTimeoutTarget(for: peer)?.nonce == fresh?.watchdogNonce)
}
@Test func freshGenerationRejectsFingerprintMismatchedWaiters() {
let store = BLEPrivateMediaSessionStore()
var completed = 0
_ = store.registerPolicyResolution(
for: peer, fingerprint: fingerprint, requestID: UUID(),
completion: { _ in completed += 1 }
)
// The replacement session authenticates a DIFFERENT identity: the
// old waiters must come back for rejection instead of riding along.
let fresh = store.beginAuthenticatedGeneration(
for: peer, fingerprint: "FEDCBA9876543210", generation: UUID()
)
#expect(fresh?.rejected.count == 1)
#expect(!store.hasPendingPolicyResolution(for: peer))
}
@Test func applyPeerStateRequiresTheCurrentGenerationAndReleasesWaiters() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
_ = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
_ = store.registerPolicyResolution(
for: peer, fingerprint: fingerprint, requestID: UUID(),
completion: { _ in }
)
// A proof bound to a superseded generation must not classify the
// replacement session.
let stale = store.applyAuthenticatedPeerState(
for: peer, fingerprint: fingerprint, generation: UUID(),
capabilities: [.privateMedia]
)
#expect(stale == nil)
let released = store.applyAuthenticatedPeerState(
for: peer, fingerprint: fingerprint, generation: generation,
capabilities: [.privateMedia]
)
#expect(released?.count == 1)
// Proof landed: the watchdog is retired, so nothing can time out.
#expect(store.proofTimeoutTarget(for: peer) == nil)
}
@Test func expiryRequiresTheLiveDeadlineIdentityAndPinsTheMarker() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
let fresh = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
let nonce = fresh?.watchdogNonce ?? UUID()
// A stale nonce (superseded deadline) must not expire anything.
let stale = store.expireProofDeadline(
for: peer, fingerprint: fingerprint,
sessionGeneration: generation, nonce: UUID()
)
#expect(!stale.expired)
let expired = store.expireProofDeadline(
for: peer, fingerprint: fingerprint,
sessionGeneration: generation, nonce: nonce
)
#expect(expired.expired)
#expect(!expired.deferredOutbound)
// The timeout marker now classifies this generation as unproven.
#expect(store.policyInputs(for: peer).timedOut != nil)
}
@Test func expiryReportsTheConvergenceDeferralSoDrainsStayParked() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
let fresh = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
store.setOutboundDeferredUntilConvergence(peer)
let expired = store.expireProofDeadline(
for: peer, fingerprint: fingerprint,
sessionGeneration: generation, nonce: fresh?.watchdogNonce ?? UUID()
)
#expect(expired.expired)
#expect(expired.deferredOutbound)
store.clearOutboundDeferredUntilConvergence(peer)
// Deferral is per-peer state, not per-deadline: once convergence
// clears it, a later expiry may drain.
_ = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: UUID()
)
let next = store.proofTimeoutTarget(for: peer)
let afterClear = store.expireProofDeadline(
for: peer, fingerprint: fingerprint,
sessionGeneration: next?.generation ?? nil, nonce: next?.nonce ?? UUID()
)
#expect(afterClear.expired)
#expect(!afterClear.deferredOutbound)
}
@Test func policyWaitersReuseTheLiveWatchdogDeadline() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
let fresh = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
// First waiter piggybacks on the watchdog's deadline (case-insensitive
// fingerprint match): no second timeout gets scheduled.
let first = store.registerPolicyResolution(
for: peer, fingerprint: fingerprint.lowercased(), requestID: UUID(),
completion: { _ in }
)
#expect(first.registered)
#expect(!first.shouldSchedule)
#expect(first.nonce == fresh?.watchdogNonce)
// Later waiters join the existing set.
let second = store.registerPolicyResolution(
for: peer, fingerprint: fingerprint, requestID: UUID(),
completion: { _ in }
)
#expect(second.registered)
#expect(!second.shouldSchedule)
}
@Test func clearSessionRebasesWaitersOntoANilGenerationDeadline() {
let store = BLEPrivateMediaSessionStore()
let generation = UUID()
_ = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: generation
)
_ = store.registerPolicyResolution(
for: peer, fingerprint: fingerprint, requestID: UUID(),
completion: { _ in }
)
store.setOutboundDeferredUntilConvergence(peer)
let rearm = store.clearSession(for: peer)
// Waiters survive the clear but their deadline is rebased so the
// old generation's timeout can no longer claim them.
#expect(rearm != nil)
#expect(store.currentGeneration(for: peer) == nil)
#expect(store.hasPendingPolicyResolution(for: peer))
let target = store.proofTimeoutTarget(for: peer)
#expect(target?.generation == nil)
#expect(target?.nonce == rearm?.nonce)
}
@Test func peerStateSendsAreOncePerGenerationPerKind() {
let store = BLEPrivateMediaSessionStore()
_ = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: UUID()
)
#expect(store.markPeerStateSend(for: peer, echo: false))
#expect(!store.markPeerStateSend(for: peer, echo: false))
#expect(store.markPeerStateSend(for: peer, echo: true))
#expect(!store.markPeerStateSend(for: peer, echo: true))
// A fresh generation resets both slots.
_ = store.beginAuthenticatedGeneration(
for: peer, fingerprint: fingerprint, generation: UUID()
)
#expect(store.markPeerStateSend(for: peer, echo: false))
}
}

View File

@ -0,0 +1,85 @@
import Foundation
import Testing
/// Pins the BLE transport's sync-edge order so it stays structural instead
/// of decaying back into per-site discipline:
///
/// main / test threads sync engine sync bleQueue
/// sync noise / identity queues
///
/// and never the reverse. `onEngine` is the single place allowed to
/// sync-enter the engine it carries the debug trap that catches a
/// bleQueue caller before it can pair with `readLinkState` into an ABBA
/// deadlock (the class of the July 9 field freeze). A raw
/// `messageQueue.sync` bypasses that trap, and a `DispatchQueue.main.sync`
/// from transport code completes a cycle with the main actor's sync reads.
///
/// Waive a line with `queue-contract-ok:` plus a reason.
struct BLEQueueContractTests {
static let waiver = "queue-contract-ok:"
private static let bleRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // Services
.deletingLastPathComponent() // bitchatTests
.deletingLastPathComponent() // repo root
.appendingPathComponent("bitchat/Services/BLE")
private struct Line {
let file: String
let number: Int
let text: String
let waived: Bool
}
private static func bleLines() throws -> [Line] {
let enumerator = FileManager.default.enumerator(
at: bleRoot,
includingPropertiesForKeys: nil
)
var out: [Line] = []
while let url = enumerator?.nextObject() as? URL {
guard url.pathExtension == "swift" else { continue }
let name = url.lastPathComponent
let texts = try String(contentsOf: url, encoding: .utf8)
.components(separatedBy: .newlines)
for (index, text) in texts.enumerated() {
var waived = text.contains(waiver)
var back = index - 1
while !waived, back >= 0 {
let previous = texts[back].trimmingCharacters(in: .whitespaces)
guard previous.hasPrefix("//") else { break }
waived = previous.contains(waiver)
back -= 1
}
out.append(Line(file: name, number: index + 1, text: text, waived: waived))
}
}
return out
}
private func offenders(matching pattern: String) throws -> [String] {
try Self.bleLines()
.filter { !$0.waived && $0.text.contains(pattern) }
.map { "\($0.file):\($0.number): \($0.text.trimmingCharacters(in: .whitespaces))" }
}
@Test func onlyOnEngineSyncEntersTheEngine() throws {
let hits = try offenders(matching: "messageQueue.sync")
#expect(hits.isEmpty, "Raw messageQueue.sync bypasses onEngine's bleQueue trap; route through onEngine (or waive with a reason): \(hits)")
}
@Test func transportCodeNeverSyncDispatchesToMain() throws {
let hits = try offenders(matching: "DispatchQueue.main.sync")
#expect(hits.isEmpty, "A main.sync from transport code can complete an ABBA cycle with the main actor's sync reads: \(hits)")
}
@Test func theCollectionsQueueStaysDeleted() throws {
let hits = try offenders(matching: "collectionsQueue")
#expect(hits.isEmpty, "Engine state has exactly one serial domain; do not reintroduce a side queue: \(hits)")
}
@Test func deferredEngineWorkGoesThroughTheScheduler() throws {
let hits = try offenders(matching: "messageQueue.asyncAfter")
#expect(hits.isEmpty, "Engine delays must use BLEEngineScheduling so tests can drive protocol deadlines with a manual clock: \(hits)")
}
}

175
docs/BLE-ARCHITECTURE-V3.md Normal file
View File

@ -0,0 +1,175 @@
# BLE Transport Architecture V3
The plan of record for restructuring `BLEService` from an 8.3k-line god
object into a layered mesh stack. ARCHITECTURE_V2 rebuilt the app layer
above the transport and deliberately deferred the transport itself; this
document covers that remainder: what already landed, the target shape, and
the order for the rest.
## Why the satellite strategy stalled
V2's transport approach was to peel pure policies and closure-driven
handlers out of `BLEService` while the class kept coordinating. The ~30
pure policy structs were a clear win. The five big handler extractions
were not: each needed an "environment" of 2030 closures that weakly
capture the service and hop queues back into its state. Logic left, but
state ownership and synchronization never moved, so extraction paid a
plumbing tax that grew as fast as the logic shrank — the five
`make*HandlerEnvironment()` factories alone were ~1.5k lines. The file
held ~60 mutable fields across four concurrency domains whose ownership
lived in comments, and every new feature added Transport requirements,
state maps, and switch cases to the same class.
Two chronic costs came straight from that structure: queue-order
deadlocks (the July 9 main↔bleQueue ABBA freeze), and timing-dependent
tests (correctness only observable through real queues and real time).
## Target shape
A packet-radio stack with one rule per layer about state and threads:
1. **`BLELinkLayer`** — the only CoreBluetooth import. Owns both managers,
scanning/advertising, duty cycle, connection scheduling, MTU, write
and notification backpressure buffers, state restoration. Speaks
`LinkEvent` up (link up/down, bytes in, writable) and `LinkCommand`
down (send bytes on link, scan/advertise policy). Knows nothing about
packets, peers, or Noise. bleQueue-confined. A `SimulatedLinkLayer`
implementing the same port gives multi-node tests real topologies with
no radios and no wall-clock waits.
2. **Mesh engine** — one serial queue owning all protocol state: wire
codec, fragmentation, dedup, relay policy, peer registry, topology,
gossip sync, Noise orchestration. Synchronous single-writer logic; the
pure policy satellites slot in unchanged. Endgame: the engine core
becomes `handle(event, now) -> [Effect]` (sans-I/O), which makes the
whole mesh property-testable and fuzzable in simulation.
3. **Feature modules** — courier, board, prekeys, private media, file
transfer, voice, diagnostics, groups, verify/vouch each own their
state and register for their message types. A new feature is a new
module, not edits to the engine.
4. **App boundary** — a small `Transport` core both transports genuinely
implement, plus capability protocols discovered with `as?`
(`MeshBridgingTransport` etc.), replacing the ~90-requirement
god-protocol and its inert defaults.
### Concurrency contract
State is owned one of three ways:
- **Engine-confined** — mutated only on the serial engine queue
(`mesh.message`). Cross-thread callers use `onEngine`.
- **bleQueue-confined** — link-layer state next to CoreBluetooth objects
(link store, write/notification buffers, link-auth maps).
- **Lock-backed store** — state with legitimate cross-domain readers
(peer registry, local identity/capabilities, traffic monitor). Writes
still come from one domain; the lock exists so readers never block on
a queue. Every mutation is a single whole-transition method, so
readers never observe torn state.
Sync-edge order (deadlock freedom by construction, debug-enforced in
`onEngine`):
```
main / test threads ──sync──▶ engine ──sync──▶ bleQueue
└──sync──▶ noise / identity queues (leaves)
```
Nothing may sync-wait in the reverse direction: bleQueue and the crypto
queues reach the engine only via `async`, and nothing sync-dispatches to
main. Two subtleties worth knowing:
- A closure executed inside a noise-manager critical section entered
*from* an engine slot may touch engine state directly (the blocked
slot makes it exclusive) but must never sync-re-enter the engine —
that is a self-deadlock.
- bleQueue critical sections (e.g. the verified-announce link rebind)
must receive engine-derived values as arguments rather than fetching
them through `onEngine`.
## What landed in this pass
- **Lock-backed peer state** (`BLEPeerRegistryStore`): every main-actor
Transport read (`isPeerConnected`, nicknames, snapshots, capability
queries) reads a lock, not a queue. Runtime capability bits moved into
`BLELocalIdentityStateStore` beside the identity they ride announces
with.
- **bleQueue owns the link buffers**: `pendingPeripheralWrites`,
`pendingNotifications`, `pendingWriteBuffers` are bleQueue-confined
(their producers and drains already ran there); the notification drain
no longer invokes CoreBluetooth from a transport queue.
- **One serial engine queue**: the concurrent message queue and the
collections queue it guarded state with are one serial domain; every
barrier flag and per-field ownership comment deleted; ~98 cross-queue
hops removed. `onEngine` documents and debug-enforces the sync-edge
order — and its trap caught two latent inversions during migration
(the announce-rebind path and the noise session-generation closures).
- **Capability ports**: gateway/bridge/courier wiring, the panic
lifecycle, and radio-state reads go through `MeshBridgingTransport`,
`PanicResettingTransport`, and `BluetoothStateReporting`; no app code
casts to `BLEService` anymore.
- **Feature-owned state**: `BLEMeshPingTracker` (the /ping probe map and
per-link response budget) and `BLEPrivateMediaSessionStore` (the six
generation-keyed private-media maps plus the convergence-deferral set,
as whole-transition methods under a leaf lock), both with direct unit
tests. The private-media store also took the last routine main-actor
sync reads off the engine and turned the noise-critical-section
transitions into ordinary leaf-lock calls. Remaining feature state
(courier, board, prekeys) already lives in injected stores.
- **Transport split**: the mesh-only surface left the god-protocol.
`Transport` is core only (lifecycle, identity, snapshots, basic
messaging, noise wrappers); files/private media, voice, courier,
groups, board, diagnostics, verification, and the public archive are
eight capability protocols discovered with `as?`, alongside the
bridging/panic/radio-state ports. The inert-defaults extension is
gone; consumers that relied on a default keep its safe floor
explicitly at the call site.
- **Contract pinning**: `BLEQueueContractTests` greps the transport
sources — only `onEngine` may sync-enter the engine, transport code
never sync-dispatches to main, and the collections queue stays
deleted (waivable per line with `queue-contract-ok:` plus a reason).
Full suite green throughout (1,964 tests), identical wall-clock — BLE
throughput is nowhere near what one serial queue sustains.
## Remaining roadmap (in order)
1. **Link-layer extraction.** Move the CB delegates, scheduling, duty
cycle, and buffers behind `LinkEvent`/`LinkCommand` ports.
**Link-auth boundary (decided): bindings become engine-owned.**
Today `noiseAuthenticatedLinkOwners`, the rebind containment rules,
and the peer↔link binding maps live on bleQueue so that "check
binding + auth, then act" is one critical section (the rebind path
and the authenticated-send commit point in
`notifyOrEnqueueIfAccepted`). That atomicity exists to stop a
binding from changing between a security check and its action — and
the engine's serial slot provides exactly the same guarantee once
every rebind is an engine operation. The residual stolen-link risk
is unchanged: directed payloads are Noise ciphertext, useless on a
link that changed hands after the decision. Making bindings engine
state also puts the receive path in its sans-I/O shape: the link
layer reports `received(bytes, linkID)` and the engine resolves the
sender binding, instead of the CB delegate resolving peers before
handoff. The link layer keeps only physical link state (CB objects,
connect/subscribe lifecycles, backpressure buffers) keyed by opaque
link IDs.
Extraction order: (a) the binding-free radio half — scanning,
advertising, duty cycle, connection budget/scheduling — moves first
(it makes no peer decisions); (b) bindings + link-auth migrate to
the engine, converting `readLinkState` callers; (c) the delegates
shrink to event emission and move behind the port.
2. **Sans-I/O engine core + simulator.** Make the engine formally
`handle(event) -> [Effect]`, feed it from a `SimulatedLinkLayer`, and
move the multi-node E2E suite onto deterministic simulation (no
`waitUntil`, no timing hygiene battles). Property tests become
possible: relay-storm bounds, partition-heal convergence, dedup
soundness under duplicate floods. The remaining feature *code* moves
(courier, board, prekey, voice, file, group handlers out of the
packet switch) ride this seam as handler-registered modules instead
of getting closure-environment extractions now.
## What this is not
No wire changes: packet formats, signing (padding is signed), the
peerID identity binding, and courier tag construction are untouched —
see the wire-landmines notes before assuming any of that is local.