mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
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:
parent
eadd3a20c1
commit
c6b7096b2f
@ -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
|
||||
|
||||
39
bitchat/Services/BLE/BLEEngineScheduler.swift
Normal file
39
bitchat/Services/BLE/BLEEngineScheduler.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
62
bitchat/Services/BLE/BLEMeshPingTracker.swift
Normal file
62
bitchat/Services/BLE/BLEMeshPingTracker.swift
Normal 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
|
||||
}
|
||||
}
|
||||
84
bitchat/Services/BLE/BLEPeerRegistryStore.swift
Normal file
84
bitchat/Services/BLE/BLEPeerRegistryStore.swift
Normal 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(®istry) }
|
||||
}
|
||||
|
||||
// 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
363
bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift
Normal file
363
bitchat/Services/BLE/BLEPrivateMediaSessionStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
@ -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.
|
||||
|
||||
@ -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 —
|
||||
|
||||
152
bitchat/Services/MeshTransportCapabilities.swift
Normal file
152
bitchat/Services/MeshTransportCapabilities.swift
Normal 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 }
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -279,7 +279,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
// Purge while the fingerprint↔peerID 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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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?
|
||||
|
||||
@ -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 },
|
||||
|
||||
49
bitchatTests/Mocks/BLEEngineManualScheduler.swift
Normal file
49
bitchatTests/Mocks/BLEEngineManualScheduler.swift
Normal 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 {}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
84
bitchatTests/Services/BLEMeshPingTrackerTests.swift
Normal file
84
bitchatTests/Services/BLEMeshPingTrackerTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
192
bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift
Normal file
192
bitchatTests/Services/BLEPrivateMediaSessionStoreTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
85
bitchatTests/Services/BLEQueueContractTests.swift
Normal file
85
bitchatTests/Services/BLEQueueContractTests.swift
Normal 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
175
docs/BLE-ARCHITECTURE-V3.md
Normal 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 20–30 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.
|
||||
Loading…
x
Reference in New Issue
Block a user