mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 11:25:20 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e69a19cb3 | ||
|
|
5c2903ec7c | ||
|
|
cbfdb9696e | ||
|
|
87cbf5a03c | ||
|
|
d2a47d1ade | ||
|
|
0bde6d21f7 | ||
|
|
f718097c74 |
@@ -5,6 +5,20 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable {
|
|||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
let peerIDData: Data
|
let peerIDData: Data
|
||||||
let nickname: String
|
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,
|
/// 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
|
/// `peerID` and its binary wire representation must change as one unit during
|
||||||
/// panic rotation. A snapshot also gives announce construction one consistent
|
/// panic rotation. A snapshot also gives announce construction one consistent
|
||||||
/// view of the nickname and identity instead of reading three independently
|
/// view of the nickname, identity, and advertised capabilities instead of
|
||||||
/// mutable properties across queues.
|
/// reading independently mutable properties across queues.
|
||||||
final class BLELocalIdentityStateStore: @unchecked Sendable {
|
final class BLELocalIdentityStateStore: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var state: BLELocalIdentitySnapshot
|
private var state: BLELocalIdentitySnapshot
|
||||||
@@ -25,7 +39,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
|
|||||||
state = BLELocalIdentitySnapshot(
|
state = BLELocalIdentitySnapshot(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||||
nickname: nickname
|
nickname: nickname,
|
||||||
|
runtimeCapabilities: [],
|
||||||
|
bridgeGeohash: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +54,9 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
|
|||||||
state = BLELocalIdentitySnapshot(
|
state = BLELocalIdentitySnapshot(
|
||||||
peerID: state.peerID,
|
peerID: state.peerID,
|
||||||
peerIDData: state.peerIDData,
|
peerIDData: state.peerIDData,
|
||||||
nickname: nickname
|
nickname: nickname,
|
||||||
|
runtimeCapabilities: state.runtimeCapabilities,
|
||||||
|
bridgeGeohash: state.bridgeGeohash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,8 +66,48 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
|
|||||||
state = BLELocalIdentitySnapshot(
|
state = BLELocalIdentitySnapshot(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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 stay serialized by the transport (they only run on its
|
||||||
|
/// queues), so the lock's job is to let the main actor answer 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 count: Int { read { $0.count } }
|
||||||
|
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 capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
|
||||||
|
read { $0.capabilitiesWereExplicitlyAdvertised(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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
struct BLERecentTrafficTracker: Equatable {
|
||||||
private var packetTimestamps: [Date] = []
|
private var packetTimestamps: [Date] = []
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 }
|
||||||
|
}
|
||||||
@@ -450,3 +450,6 @@ extension BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension BLEService: Transport {}
|
extension BLEService: Transport {}
|
||||||
|
extension BLEService: BluetoothStateReporting {}
|
||||||
|
extension BLEService: PanicResettingTransport {}
|
||||||
|
extension BLEService: MeshBridgingTransport {}
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ extension ChatViewModel: ChatLifecycleContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func refreshBluetoothState() {
|
func refreshBluetoothState() {
|
||||||
if let bleService = meshService as? BLEService {
|
if let radio = meshService as? BluetoothStateReporting {
|
||||||
updateBluetoothState(bleService.getCurrentBluetoothState())
|
updateBluetoothState(radio.getCurrentBluetoothState())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1564,8 +1564,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
|
|
||||||
// Quiesce the mesh before clearing stores. Identity replacement below
|
// Quiesce the mesh before clearing stores. Identity replacement below
|
||||||
// deliberately stays stopped until media deletion and marker commit.
|
// deliberately stays stopped until media deletion and marker commit.
|
||||||
if let bleService = meshService as? BLEService {
|
if let panicTransport = meshService as? PanicResettingTransport {
|
||||||
bleService.suspendForPanicReset()
|
panicTransport.suspendForPanicReset()
|
||||||
} else {
|
} else {
|
||||||
meshService.emergencyDisconnectAll()
|
meshService.emergencyDisconnectAll()
|
||||||
}
|
}
|
||||||
@@ -1700,8 +1700,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
|
|
||||||
// Replace the BLE identity while keeping the radio stopped. It may
|
// Replace the BLE identity while keeping the radio stopped. It may
|
||||||
// reopen only after the durable panic transaction commits.
|
// reopen only after the durable panic transaction commits.
|
||||||
if let bleService = meshService as? BLEService {
|
if let panicTransport = meshService as? PanicResettingTransport {
|
||||||
bleService.resetIdentityForPanic(
|
panicTransport.resetIdentityForPanic(
|
||||||
currentNickname: nickname,
|
currentNickname: nickname,
|
||||||
restartServices: false
|
restartServices: false
|
||||||
)
|
)
|
||||||
@@ -1746,18 +1746,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
|
|
||||||
guard panicCompleted else { return false }
|
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
|
// Startup recovery reopens admission but leaves actual service
|
||||||
// start to the bootstrapper immediately after this method.
|
// start to the bootstrapper immediately after this method.
|
||||||
bleService.completePanicReset(
|
panicTransport.completePanicReset(
|
||||||
restartServices: restartServices
|
restartServices: restartServices
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if restartServices {
|
if restartServices {
|
||||||
// All persistent state and media are gone. Bring each service back
|
// All persistent state and media are gone. Bring each service back
|
||||||
// only now, under the new identity.
|
// only now, under the new identity — a panic-resetting transport
|
||||||
if !(meshService is BLEService) {
|
// owns its own restart sequencing above.
|
||||||
|
if !(meshService is PanicResettingTransport) {
|
||||||
meshService.startServices()
|
meshService.startServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,9 +195,8 @@ private extension ChatViewModelBootstrapper {
|
|||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
|
||||||
guard let viewModel,
|
guard let viewModel,
|
||||||
let bleService = viewModel.meshService as? BLEService else { return }
|
let radio = viewModel.meshService as? BluetoothStateReporting else { return }
|
||||||
let state = bleService.getCurrentBluetoothState()
|
viewModel.updateBluetoothState(radio.getCurrentBluetoothState())
|
||||||
viewModel.updateBluetoothState(state)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.nostrRelayManager = NostrRelayManager.shared
|
viewModel.nostrRelayManager = NostrRelayManager.shared
|
||||||
@@ -331,7 +330,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
func configureGateway() {
|
func configureGateway() {
|
||||||
// Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
|
// Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
|
||||||
// has no carrier packets to bridge.
|
// 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
|
let gateway = GatewayService.shared
|
||||||
|
|
||||||
gateway.publishToRelays = { event, geohash in
|
gateway.publishToRelays = { event, geohash in
|
||||||
@@ -410,7 +409,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
/// transport, the relay manager, location, and the public timeline. Same
|
/// transport, the relay manager, location, and the public timeline. Same
|
||||||
/// closure-injection style as `configureGateway`.
|
/// closure-injection style as `configureGateway`.
|
||||||
func configureBridge() {
|
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 bridge = BridgeService.shared
|
||||||
let idBridge = viewModel.idBridge
|
let idBridge = viewModel.idBridge
|
||||||
|
|
||||||
@@ -545,7 +544,7 @@ private extension ChatViewModelBootstrapper {
|
|||||||
/// manager, the mesh transport's sealing/opening primitives, the courier
|
/// manager, the mesh transport's sealing/opening primitives, the courier
|
||||||
/// store, and the message router's deposit path.
|
/// store, and the message router's deposit path.
|
||||||
func configureBridgeCourier() {
|
func configureBridgeCourier() {
|
||||||
guard let bleService = viewModel.meshService as? BLEService else { return }
|
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
|
||||||
let courier = BridgeCourierService.shared
|
let courier = BridgeCourierService.shared
|
||||||
|
|
||||||
courier.bridgeEnabled = { BridgeService.shared.isEnabled }
|
courier.bridgeEnabled = { BridgeService.shared.isEnabled }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# 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.
|
||||||
|
- **First feature-owned state**: `BLEMeshPingTracker` holds the /ping
|
||||||
|
probe map and per-link response budget as pure state with unit tests —
|
||||||
|
the template for peeling the remaining features.
|
||||||
|
|
||||||
|
Full suite green throughout (1,953 tests), identical wall-clock — BLE
|
||||||
|
throughput is nowhere near what one serial queue sustains.
|
||||||
|
|
||||||
|
## Remaining roadmap (in order)
|
||||||
|
|
||||||
|
1. **Feature peeling.** Move each feature's state maps and handlers into
|
||||||
|
a module in the `BLEMeshPingTracker` mold: private media (six
|
||||||
|
generation-keyed maps + policy resolution — its main-actor reads
|
||||||
|
become a lock-backed store inside the module), courier, prekeys,
|
||||||
|
board, voice, file transfer, groups. The engine keeps a registry of
|
||||||
|
handled message types instead of a giant switch. Each module lands as
|
||||||
|
its own PR with its state's invariants unit-tested.
|
||||||
|
2. **Transport protocol split.** Continue what the capability ports
|
||||||
|
started: `Transport` shrinks to lifecycle + identity + snapshots +
|
||||||
|
basic messaging; files/voice/courier/board/diagnostics/verification
|
||||||
|
become capability protocols; `NostrTransport` drops its inert stubs;
|
||||||
|
coordinators declare the capability they need instead of receiving
|
||||||
|
the whole god-protocol (~48 call sites across 14 files).
|
||||||
|
3. **Link-layer extraction.** With the file slimmed, move the CB
|
||||||
|
delegates, scheduling, duty cycle, and buffers behind
|
||||||
|
`LinkEvent`/`LinkCommand` ports. Decide the link-auth boundary here:
|
||||||
|
`noiseAuthenticatedLinkOwners` and the rebind containment rules are
|
||||||
|
mesh security state that currently lives on bleQueue for atomicity
|
||||||
|
with bindings — the port design must keep "binding + auth check" one
|
||||||
|
critical section or make bindings engine-owned.
|
||||||
|
4. **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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
Reference in New Issue
Block a user