Files
BeamScribe/transition.md
jared 0c1e3d6fff Refactor networking to use Network.framework
Replace MultipeerConnectivity with a custom P2P implementation using Network.framework (NWListener/NWConnection) and CoreBluetooth for discovery.
- Add P2PConnectionManager, BLEDiscoveryManager, and NetworkFraming.
- Add ConnectedPeer and DiscoveredHost models.
- Update Info.plist with local network and bluetooth permissions.
- Refactor Views to use P2PConnectionManager.
- Add implementation plan and transition docs.
2026-01-20 23:48:14 -05:00

38 KiB
Raw Blame History

MultipeerConnectivity to Network.framework Transition Guide

This document explains how to transition an iOS app from MultipeerConnectivity (MPC) to Network.framework for peer-to-peer networking.


Why Transition?

  • Network.framework provides lower-level control over connections
  • Better performance with direct TCP/UDP streams
  • More flexible discovery options (can combine with BLE for faster discovery)
  • AWDL (Apple Wireless Direct Link) support via includePeerToPeer = true
  • Future-proof: Network.framework is Apple's modern networking API

Architecture Overview

MultipeerConnectivity Architecture

MCNearbyServiceAdvertiser  →  Advertises service
MCNearbyServiceBrowser     →  Discovers services
MCSession                  →  Manages connections & data transfer
MCPeerID                   →  Identifies peers

Network.framework Architecture

NWListener                 →  Advertises service (replaces MCNearbyServiceAdvertiser)
NWBrowser                  →  Discovers services (replaces MCNearbyServiceBrowser)
NWConnection               →  Individual connection per peer (replaces MCSession)
UUID / Custom struct       →  Identifies peers (replaces MCPeerID)

New Files to Create

1. Models

DiscoveredHost.swift - Represents a discovered host

import Foundation

struct DiscoveredHost: Identifiable, Hashable {
    let id: UUID
    let deviceName: String
    let eventName: String
    let discoveredAt: Date
    let endpointID: String  // Unique identifier from NWBrowser

    init(deviceName: String, eventName: String, endpointID: String) {
        self.id = UUID()
        self.deviceName = deviceName
        self.eventName = eventName
        self.discoveredAt = Date()
        self.endpointID = endpointID
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(endpointID)
    }

    static func == (lhs: DiscoveredHost, rhs: DiscoveredHost) -> Bool {
        lhs.endpointID == rhs.endpointID
    }
}

ConnectedPeer.swift - Represents a connected peer

import Foundation

struct ConnectedPeer: Identifiable, Hashable {
    let id: UUID
    let displayName: String
    let connectedAt: Date
    var isStable: Bool  // For traffic gate pattern

    init(displayName: String, isStable: Bool = false) {
        self.id = UUID()
        self.displayName = displayName
        self.connectedAt = Date()
        self.isStable = isStable
    }
}

enum P2PConnectionState {
    case idle
    case hosting
    case browsing
    case connecting
    case connected
    case disconnected
    case failed(Error)
}

2. NetworkFraming.swift - TCP Message Framing

Network.framework uses raw TCP streams, so you need length-prefixed framing:

import Foundation
import Network

enum NetworkFraming {
    static let headerSize = 4  // UInt32 for length
    static let maxMessageSize: UInt32 = 10_485_760  // 10 MB

    /// Frame data with 4-byte length prefix
    static func frameData(_ data: Data) -> Data {
        var length = UInt32(data.count).bigEndian
        var framedData = Data(bytes: &length, count: headerSize)
        framedData.append(data)
        return framedData
    }

    /// Send framed data over connection
    static func send(_ data: Data, over connection: NWConnection, completion: @escaping (Error?) -> Void) {
        let framedData = frameData(data)
        connection.send(content: framedData, completion: .contentProcessed { error in
            completion(error)
        })
    }

    /// Receive framed data from connection
    static func receive(from connection: NWConnection, completion: @escaping (Result<Data, Error>) -> Void) {
        // Read 4-byte header
        connection.receive(minimumIncompleteLength: headerSize, maximumLength: headerSize) { headerData, _, isComplete, error in
            if let error = error {
                completion(.failure(error))
                return
            }

            guard let headerData = headerData, headerData.count == headerSize else {
                completion(.failure(FramingError.incompleteHeader))
                return
            }

            let length = headerData.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }

            guard length > 0, length <= maxMessageSize else {
                completion(.failure(FramingError.invalidMessageSize(length)))
                return
            }

            // Read payload
            connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { payloadData, _, _, error in
                if let error = error {
                    completion(.failure(error))
                    return
                }

                guard let payloadData = payloadData else {
                    completion(.failure(FramingError.incompletePayload))
                    return
                }

                completion(.success(payloadData))
            }
        }
    }

    /// Continuous receive loop
    static func startReceiving(from connection: NWConnection, onData: @escaping (Data) -> Void, onError: @escaping (Error) -> Void) {
        receive(from: connection) { result in
            switch result {
            case .success(let data):
                onData(data)
                startReceiving(from: connection, onData: onData, onError: onError)
            case .failure(let error):
                onError(error)
            }
        }
    }
}

enum FramingError: LocalizedError {
    case incompleteHeader
    case incompletePayload
    case invalidMessageSize(UInt32)

    var errorDescription: String? {
        switch self {
        case .incompleteHeader: return "Incomplete header"
        case .incompletePayload: return "Incomplete payload"
        case .invalidMessageSize(let size): return "Invalid size: \(size)"
        }
    }
}

3. BLEDiscoveryManager.swift (Optional - For Fast Discovery)

BLE advertisement wakes AWDL faster than Bonjour alone:

import Foundation
import CoreBluetooth
import Combine

@MainActor
class BLEDiscoveryManager: NSObject, ObservableObject {
    @Published var isAdvertising = false
    @Published var isScanning = false

    var onHostDiscovered: ((String) -> Void)?

    // Use a valid UUID (hex characters only: 0-9, A-F)
    private let serviceUUID = CBUUID(string: "YOUR-UUID-HERE")
    private var peripheralManager: CBPeripheralManager?
    private var centralManager: CBCentralManager?
    private var eventName = ""
    private var discoveredPeripherals: Set<UUID> = []

    // MARK: - Host Mode (Peripheral)

    func startAdvertising(eventName: String) {
        self.eventName = eventName
        peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
    }

    func stopAdvertising() {
        peripheralManager?.stopAdvertising()
        peripheralManager = nil
        isAdvertising = false
    }

    // MARK: - Guest Mode (Central)

    func startScanning() {
        discoveredPeripherals.removeAll()
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }

    func stopScanning() {
        centralManager?.stopScan()
        centralManager = nil
        isScanning = false
    }
}

extension BLEDiscoveryManager: CBPeripheralManagerDelegate {
    nonisolated func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        Task { @MainActor in
            if peripheral.state == .poweredOn {
                let truncatedName = String(eventName.prefix(8))  // BLE has 28-byte limit
                peripheral.startAdvertising([
                    CBAdvertisementDataServiceUUIDsKey: [serviceUUID],
                    CBAdvertisementDataLocalNameKey: truncatedName
                ])
                isAdvertising = true
            }
        }
    }
}

extension BLEDiscoveryManager: CBCentralManagerDelegate {
    nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
        Task { @MainActor in
            if central.state == .poweredOn {
                central.scanForPeripherals(withServices: [serviceUUID])
                isScanning = true
            }
        }
    }

    nonisolated func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) {
        Task { @MainActor in
            guard !discoveredPeripherals.contains(peripheral.identifier) else { return }
            discoveredPeripherals.insert(peripheral.identifier)

            let hostName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown"
            onHostDiscovered?(hostName)
        }
    }
}

Key Implementation Patterns

1. Network Parameters Configuration

private func createNetworkParameters() -> NWParameters {
    let parameters = NWParameters.tcp
    parameters.includePeerToPeer = true  // Enables AWDL
    parameters.serviceClass = .responsiveData  // Low-latency

    let options = NWProtocolTCP.Options()
    options.enableKeepalive = true
    options.keepaliveInterval = 30
    parameters.defaultProtocolStack.transportProtocol = options

    return parameters
}

2. Host Mode - NWListener (replaces MCNearbyServiceAdvertiser)

func startHosting(eventName: String) {
    let parameters = createNetworkParameters()
    listener = try NWListener(using: parameters)

    // Include device name in TXT record
    let deviceName = UIDevice.current.name
    let txtEntry = "deviceName=\(deviceName)"
    var txtData = Data()
    txtData.append(UInt8(txtEntry.utf8.count))
    txtData.append(contentsOf: txtEntry.utf8)

    listener?.service = NWListener.Service(
        name: eventName,
        type: "_yourservice._tcp",
        txtRecord: txtData
    )

    listener?.stateUpdateHandler = { [weak self] state in
        guard let self else { return }
        Task { @MainActor in
            self.handleListenerState(state)
        }
    }

    listener?.newConnectionHandler = { [weak self] connection in
        guard let self else { return }
        Task { @MainActor in
            self.handleNewConnection(connection)
        }
    }

    listener?.start(queue: .main)
}

3. Guest Mode - NWBrowser (replaces MCNearbyServiceBrowser)

func startBrowsing() {
    let parameters = createNetworkParameters()
    browser = NWBrowser(for: .bonjour(type: "_yourservice._tcp", domain: nil), using: parameters)

    browser?.browseResultsChangedHandler = { [weak self] results, changes in
        guard let self else { return }
        Task { @MainActor in
            for change in changes {
                switch change {
                case .added(let result):
                    self.handleHostFound(result)
                case .removed(let result):
                    self.handleHostLost(result)
                default:
                    break
                }
            }
        }
    }

    browser?.start(queue: .main)
}

private func handleHostFound(_ result: NWBrowser.Result) {
    guard case .service(let name, let type, let domain, _) = result.endpoint else { return }

    let endpointID = "\(name).\(type).\(domain)"

    // Extract device name from TXT record
    var deviceName = "Unknown Device"
    if case .bonjour(let txtRecord) = result.metadata {
        if let name = txtRecord["deviceName"] {
            deviceName = name
        }
    }

    let host = DiscoveredHost(deviceName: deviceName, eventName: name, endpointID: endpointID)
    availableHosts[endpointID] = host
}

4. Connecting to Host (replaces invitePeer)

func joinHost(_ host: DiscoveredHost) {
    let endpoint = NWEndpoint.service(
        name: host.eventName,
        type: "_yourservice._tcp",
        domain: "local",
        interface: nil
    )

    let parameters = createNetworkParameters()
    hostConnection = NWConnection(to: endpoint, using: parameters)

    hostConnection?.stateUpdateHandler = { [weak self] state in
        guard let self else { return }
        Task { @MainActor in
            self.handleConnectionState(state)
        }
    }

    hostConnection?.start(queue: .main)
}

5. Handling New Connections (Host Side)

private func handleNewConnection(_ connection: NWConnection) {
    let peerID = UUID()

    guestConnections[peerID] = connection
    connectedPeers[peerID] = ConnectedPeer(displayName: "Guest", isStable: false)

    connection.stateUpdateHandler = { [weak self] state in
        guard let self else { return }
        Task { @MainActor in
            // Handle state changes
        }
    }

    connection.start(queue: .main)

    // Start receiving data
    NetworkFraming.startReceiving(from: connection) { [weak self] data in
        // Handle received data
    } onError: { [weak self] error in
        // Handle error, remove connection
    }

    // Traffic Gate: Wait before marking as stable
    Task { @MainActor in
        try? await Task.sleep(nanoseconds: 4_000_000_000)  // 4 seconds
        guard connectedPeers[peerID] != nil else { return }
        stablePeers.insert(peerID)
        connectedPeers[peerID]?.isStable = true
        onPeerJoined?(peerID)
    }
}

6. Broadcasting Data (replaces MCSession.send)

func broadcastData(_ data: Data) {
    // Traffic Gate: Only send to stable peers
    let stableConnections = guestConnections.filter { stablePeers.contains($0.key) }

    for (_, connection) in stableConnections {
        NetworkFraming.send(data, over: connection) { error in
            if let error = error {
                print("Send failed: \(error)")
            }
        }
    }
}

Callback Mapping

MultipeerConnectivity Network.framework
session(_:peer:didChange:) connection.stateUpdateHandler
session(_:didReceive:fromPeer:) NetworkFraming.startReceiving()
advertiser(_:didReceiveInvitationFromPeer:) listener.newConnectionHandler
browser(_:foundPeer:withDiscoveryInfo:) browser.browseResultsChangedHandler (.added)
browser(_:lostPeer:) browser.browseResultsChangedHandler (.removed)

Info.plist Updates

Add these keys for BLE discovery (if using):

<key>UIBackgroundModes</key>
<array>
    <string>bluetooth-central</string>
    <string>bluetooth-peripheral</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Uses Bluetooth to quickly discover nearby sessions.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>Uses Bluetooth to advertise sessions to nearby devices.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Uses local network for peer-to-peer connections.</string>

Keep existing Bonjour services:

<key>NSBonjourServices</key>
<array>
    <string>_yourservice._tcp</string>
</array>

Important Considerations

1. Traffic Gate Pattern

Keep the stabilization delay (4 seconds) before marking peers as stable. This prevents data loss on mixed 5G+WiFi networks.

2. Connection Retry Logic

Implement exponential backoff (1s, 2s, 4s) for connection retries, just like with MPC.

3. Swift 6 Concurrency

Use guard let self else { return } pattern before Task blocks to avoid Swift 6 warnings:

// Good pattern
connection.stateUpdateHandler = { [weak self] state in
    guard let self else { return }
    Task { @MainActor in
        self.handleState(state)
    }
}

4. Reconnection Handling

Add an onHostConnected callback to clear "disconnected" UI states when reconnecting:

var onHostConnected: (() -> Void)?

// In connection success handler:
isConnectedToHost = true
onHostConnected?()  // Clears "host lost" banners

5. Device Name in TXT Record

Network.framework doesn't expose device names like MPC's MCPeerID.displayName. Include it in the TXT record:

// Host: Add to TXT record
let txtEntry = "deviceName=\(UIDevice.current.name)"

// Guest: Extract from metadata
if case .bonjour(let txtRecord) = result.metadata {
    if let deviceName = txtRecord["deviceName"] {
        // Use deviceName
    }
}

Migration Checklist

  • Create DiscoveredHost and ConnectedPeer models
  • Create NetworkFraming utility for TCP framing
  • Create P2PConnectionManager with NWListener/NWBrowser
  • (Optional) Create BLEDiscoveryManager for fast discovery
  • Update Info.plist with BLE permissions (if using BLE)
  • Update all views to use new manager
  • Replace MCPeerID with UUID or custom struct
  • Implement traffic gate pattern (4-second stabilization)
  • Implement connection retry with exponential backoff
  • Add onHostConnected callback for reconnection handling
  • Test: Host starts, guest joins
  • Test: Multiple guests join simultaneously
  • Test: Guest leaves and rejoins
  • Test: Host disconnects, guests receive notification
  • Keep old MultipeerManager as fallback initially

File Structure

YourApp/
├── Managers/
│   ├── MultipeerManager.swift      (keep as fallback)
│   ├── P2PConnectionManager.swift  (NEW - main networking)
│   ├── BLEDiscoveryManager.swift   (NEW - optional fast discovery)
│   └── NetworkFraming.swift        (NEW - TCP framing)
├── Models/
│   ├── DiscoveredHost.swift        (NEW)
│   └── ConnectedPeer.swift         (NEW)

Full Mesh Network (Up to 8 Devices)

For apps where all devices need to communicate with each other (like a group chat or multiplayer game), you need a full mesh topology where every device connects to every other device.

Mesh Network Architecture

     Device A
    /   |   \
   /    |    \
Device B----Device C
   \    |    /
    \   |   /
     Device D

In a full mesh with N devices, each device maintains N-1 connections.

Key Differences from Host/Guest Model

Host/Guest Model Full Mesh Model
One host, multiple guests All peers are equal
Host runs NWListener only Every device runs NWListener AND NWBrowser
Guests run NWBrowser only Every device can initiate or accept connections
One-to-many communication Many-to-many communication

MeshPeer Model

import Foundation

struct MeshPeer: Identifiable, Hashable {
    let id: String          // Unique device identifier (persisted across sessions)
    let displayName: String
    let discoveredAt: Date
    var isConnected: Bool
    var isStable: Bool

    init(id: String, displayName: String) {
        self.id = id
        self.displayName = displayName
        self.discoveredAt = Date()
        self.isConnected = false
        self.isStable = false
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: MeshPeer, rhs: MeshPeer) -> Bool {
        lhs.id == rhs.id
    }
}

MeshNetworkManager

import Foundation
import Network
import UIKit
import Combine

@MainActor
class MeshNetworkManager: NSObject, ObservableObject {

    // MARK: - Published Properties

    @Published var discoveredPeers: [String: MeshPeer] = [:]
    @Published var connectedPeers: [String: MeshPeer] = [:]
    @Published var isRunning = false

    // MARK: - Callbacks

    var onMessageReceived: ((Data, MeshPeer) -> Void)?
    var onPeerConnected: ((MeshPeer) -> Void)?
    var onPeerDisconnected: ((MeshPeer) -> Void)?

    // MARK: - Private Properties

    private let serviceType = "_yourmesh._tcp"
    private let myPeerID: String  // Unique, persistent device ID
    private let myDisplayName: String

    private var listener: NWListener?
    private var browser: NWBrowser?
    private var connections: [String: NWConnection] = [:]  // peerID -> connection
    private var stablePeers: Set<String> = []
    private var pendingConnections: Set<String> = []  // Peers we're connecting to

    // MARK: - Initialization

    override init() {
        // Create or retrieve persistent device ID
        if let savedID = UserDefaults.standard.string(forKey: "meshPeerID") {
            self.myPeerID = savedID
        } else {
            let newID = UUID().uuidString
            UserDefaults.standard.set(newID, forKey: "meshPeerID")
            self.myPeerID = newID
        }
        self.myDisplayName = UIDevice.current.name
        super.init()
    }

    // MARK: - Network Parameters

    private func createParameters() -> NWParameters {
        let parameters = NWParameters.tcp
        parameters.includePeerToPeer = true
        parameters.serviceClass = .responsiveData
        return parameters
    }

    // MARK: - Start/Stop Mesh

    func startMesh() {
        guard !isRunning else { return }

        startListener()
        startBrowser()
        isRunning = true

        print("Mesh started with ID: \(myPeerID)")
    }

    func stopMesh() {
        listener?.cancel()
        listener = nil

        browser?.cancel()
        browser = nil

        for (_, connection) in connections {
            connection.cancel()
        }
        connections.removeAll()
        connectedPeers.removeAll()
        discoveredPeers.removeAll()
        stablePeers.removeAll()
        pendingConnections.removeAll()

        isRunning = false
    }

    // MARK: - Listener (Accept Incoming Connections)

    private func startListener() {
        do {
            let parameters = createParameters()
            listener = try NWListener(using: parameters)

            // Advertise with our peer ID and display name in TXT record
            let txtEntries = "peerID=\(myPeerID)\tdisplayName=\(myDisplayName)"
            var txtData = Data()
            for entry in txtEntries.split(separator: "\t") {
                let entryString = String(entry)
                txtData.append(UInt8(entryString.utf8.count))
                txtData.append(contentsOf: entryString.utf8)
            }

            listener?.service = NWListener.Service(
                name: myPeerID,  // Use peer ID as service name for uniqueness
                type: serviceType,
                txtRecord: txtData
            )

            listener?.stateUpdateHandler = { [weak self] state in
                guard let self else { return }
                Task { @MainActor in
                    if case .failed(let error) = state {
                        print("Listener failed: \(error)")
                    }
                }
            }

            listener?.newConnectionHandler = { [weak self] connection in
                guard let self else { return }
                Task { @MainActor in
                    self.handleIncomingConnection(connection)
                }
            }

            listener?.start(queue: .main)
        } catch {
            print("Failed to start listener: \(error)")
        }
    }

    // MARK: - Browser (Discover Other Peers)

    private func startBrowser() {
        let parameters = createParameters()
        browser = NWBrowser(for: .bonjour(type: serviceType, domain: nil), using: parameters)

        browser?.browseResultsChangedHandler = { [weak self] results, changes in
            guard let self else { return }
            Task { @MainActor in
                self.handleBrowseResults(changes: changes)
            }
        }

        browser?.start(queue: .main)
    }

    private func handleBrowseResults(changes: Set<NWBrowser.Result.Change>) {
        for change in changes {
            switch change {
            case .added(let result):
                handlePeerDiscovered(result)
            case .removed(let result):
                handlePeerLost(result)
            default:
                break
            }
        }
    }

    private func handlePeerDiscovered(_ result: NWBrowser.Result) {
        // Extract peer info from TXT record
        var peerID: String?
        var displayName = "Unknown"

        if case .bonjour(let txtRecord) = result.metadata {
            peerID = txtRecord["peerID"]
            displayName = txtRecord["displayName"] ?? "Unknown"
        }

        guard let peerID = peerID, peerID != myPeerID else { return }  // Ignore self

        // Already connected?
        guard connections[peerID] == nil else { return }

        let peer = MeshPeer(id: peerID, displayName: displayName)
        discoveredPeers[peerID] = peer

        print("Discovered peer: \(displayName) (\(peerID))")

        // Decide who initiates connection using tie-breaker
        // Lower peer ID initiates to avoid duplicate connections
        if myPeerID < peerID && !pendingConnections.contains(peerID) {
            initiateConnection(to: peer, result: result)
        }
    }

    private func handlePeerLost(_ result: NWBrowser.Result) {
        if case .service(let name, _, _, _) = result.endpoint {
            // Service name is the peer ID
            discoveredPeers.removeValue(forKey: name)
        }
    }

    // MARK: - Connection Management

    /// Initiate outgoing connection (we have lower ID)
    private func initiateConnection(to peer: MeshPeer, result: NWBrowser.Result) {
        pendingConnections.insert(peer.id)

        let parameters = createParameters()
        let connection = NWConnection(to: result.endpoint, using: parameters)

        connection.stateUpdateHandler = { [weak self] state in
            guard let self else { return }
            Task { @MainActor in
                self.handleConnectionState(state, peerID: peer.id, isOutgoing: true)
            }
        }

        connection.start(queue: .main)
        connections[peer.id] = connection

        print("Initiating connection to: \(peer.displayName)")
    }

    /// Handle incoming connection (other peer had lower ID)
    private func handleIncomingConnection(_ connection: NWConnection) {
        // We need to identify who this connection is from
        // Send our peer ID immediately after connection is ready
        connection.stateUpdateHandler = { [weak self] state in
            guard let self else { return }
            Task { @MainActor in
                self.handleIncomingConnectionState(state, connection: connection)
            }
        }

        connection.start(queue: .main)
    }

    private func handleIncomingConnectionState(_ state: NWConnection.State, connection: NWConnection) {
        switch state {
        case .ready:
            // Wait for the remote peer to identify themselves
            receiveIdentification(from: connection)
        case .failed, .cancelled:
            connection.cancel()
        default:
            break
        }
    }

    private func receiveIdentification(from connection: NWConnection) {
        NetworkFraming.receive(from: connection) { [weak self] result in
            guard let self else { return }
            Task { @MainActor in
                switch result {
                case .success(let data):
                    if let message = try? JSONDecoder().decode(MeshMessage.self, from: data),
                       case .identification(let peerID, let displayName) = message.type {
                        self.finalizeIncomingConnection(connection, peerID: peerID, displayName: displayName)
                    }
                case .failure(let error):
                    print("Failed to receive identification: \(error)")
                    connection.cancel()
                }
            }
        }
    }

    private func finalizeIncomingConnection(_ connection: NWConnection, peerID: String, displayName: String) {
        // Check if we already have a connection to this peer
        if let existing = connections[peerID] {
            // Keep the connection from the lower ID peer
            if myPeerID < peerID {
                // We should have initiated, reject this incoming
                connection.cancel()
                return
            } else {
                // They should have initiated, close our outgoing attempt
                existing.cancel()
            }
        }

        connections[peerID] = connection

        var peer = MeshPeer(id: peerID, displayName: displayName)
        peer.isConnected = true
        connectedPeers[peerID] = peer
        discoveredPeers.removeValue(forKey: peerID)

        // Start receiving messages
        startReceiving(from: connection, peerID: peerID)

        // Start stabilization
        startStabilization(for: peerID)

        print("Incoming connection from: \(displayName)")
    }

    private func handleConnectionState(_ state: NWConnection.State, peerID: String, isOutgoing: Bool) {
        switch state {
        case .ready:
            if isOutgoing {
                // Send identification
                sendIdentification(to: peerID)

                var peer = discoveredPeers[peerID] ?? MeshPeer(id: peerID, displayName: "Unknown")
                peer.isConnected = true
                connectedPeers[peerID] = peer
                discoveredPeers.removeValue(forKey: peerID)
                pendingConnections.remove(peerID)

                // Start receiving
                if let connection = connections[peerID] {
                    startReceiving(from: connection, peerID: peerID)
                }

                // Start stabilization
                startStabilization(for: peerID)

                print("Connected to: \(peer.displayName)")
            }

        case .failed(let error):
            print("Connection failed to \(peerID): \(error)")
            removeConnection(peerID: peerID)

        case .cancelled:
            removeConnection(peerID: peerID)

        default:
            break
        }
    }

    private func sendIdentification(to peerID: String) {
        guard let connection = connections[peerID] else { return }

        let message = MeshMessage(
            type: .identification(peerID: myPeerID, displayName: myDisplayName),
            senderID: myPeerID
        )

        if let data = try? JSONEncoder().encode(message) {
            NetworkFraming.send(data, over: connection) { error in
                if let error = error {
                    print("Failed to send identification: \(error)")
                }
            }
        }
    }

    private func startReceiving(from connection: NWConnection, peerID: String) {
        NetworkFraming.startReceiving(from: connection) { [weak self] data in
            guard let self else { return }
            Task { @MainActor in
                self.handleReceivedData(data, from: peerID)
            }
        } onError: { [weak self] error in
            guard let self else { return }
            Task { @MainActor in
                print("Receive error from \(peerID): \(error)")
                self.removeConnection(peerID: peerID)
            }
        }
    }

    private func startStabilization(for peerID: String) {
        Task { @MainActor in
            try? await Task.sleep(nanoseconds: 4_000_000_000)  // 4 seconds

            guard connectedPeers[peerID] != nil else { return }

            stablePeers.insert(peerID)
            connectedPeers[peerID]?.isStable = true

            if let peer = connectedPeers[peerID] {
                onPeerConnected?(peer)
            }

            print("Peer stabilized: \(peerID)")
        }
    }

    private func removeConnection(peerID: String) {
        connections[peerID]?.cancel()
        connections.removeValue(forKey: peerID)
        pendingConnections.remove(peerID)
        stablePeers.remove(peerID)

        if let peer = connectedPeers.removeValue(forKey: peerID) {
            onPeerDisconnected?(peer)
        }
    }

    // MARK: - Message Handling

    private func handleReceivedData(_ data: Data, from peerID: String) {
        guard let message = try? JSONDecoder().decode(MeshMessage.self, from: data) else {
            return
        }

        switch message.type {
        case .identification:
            // Already handled during connection setup
            break

        case .broadcast(let payload):
            if let peer = connectedPeers[peerID] {
                onMessageReceived?(payload, peer)
            }

        case .direct(let payload):
            if let peer = connectedPeers[peerID] {
                onMessageReceived?(payload, peer)
            }
        }
    }

    // MARK: - Sending Messages

    /// Send to all connected peers
    func broadcast(_ data: Data) {
        let message = MeshMessage(type: .broadcast(payload: data), senderID: myPeerID)
        guard let encoded = try? JSONEncoder().encode(message) else { return }

        for peerID in stablePeers {
            if let connection = connections[peerID] {
                NetworkFraming.send(encoded, over: connection) { error in
                    if let error = error {
                        print("Broadcast failed to \(peerID): \(error)")
                    }
                }
            }
        }
    }

    /// Send to specific peer
    func send(_ data: Data, to peerID: String) {
        guard stablePeers.contains(peerID),
              let connection = connections[peerID] else { return }

        let message = MeshMessage(type: .direct(payload: data), senderID: myPeerID)
        guard let encoded = try? JSONEncoder().encode(message) else { return }

        NetworkFraming.send(encoded, over: connection) { error in
            if let error = error {
                print("Send failed to \(peerID): \(error)")
            }
        }
    }

    /// Send to all except specified peers
    func broadcast(_ data: Data, excluding: Set<String>) {
        let message = MeshMessage(type: .broadcast(payload: data), senderID: myPeerID)
        guard let encoded = try? JSONEncoder().encode(message) else { return }

        for peerID in stablePeers where !excluding.contains(peerID) {
            if let connection = connections[peerID] {
                NetworkFraming.send(encoded, over: connection) { error in
                    if let error = error {
                        print("Broadcast failed to \(peerID): \(error)")
                    }
                }
            }
        }
    }
}

MeshMessage Protocol

import Foundation

struct MeshMessage: Codable {
    enum MessageType: Codable {
        case identification(peerID: String, displayName: String)
        case broadcast(payload: Data)
        case direct(payload: Data)
    }

    let type: MessageType
    let senderID: String
    let timestamp: Date

    init(type: MessageType, senderID: String) {
        self.type = type
        self.senderID = senderID
        self.timestamp = Date()
    }
}

Usage Example

struct MeshChatView: View {
    @StateObject private var mesh = MeshNetworkManager()
    @State private var messages: [(String, MeshPeer)] = []
    @State private var inputText = ""

    var body: some View {
        VStack {
            // Connected peers
            HStack {
                Text("Connected: \(mesh.connectedPeers.count)")
                ForEach(Array(mesh.connectedPeers.values)) { peer in
                    Text(peer.displayName)
                        .padding(4)
                        .background(peer.isStable ? Color.green : Color.orange)
                        .cornerRadius(4)
                }
            }

            // Messages
            List(messages, id: \.0) { message, peer in
                Text("\(peer.displayName): \(message)")
            }

            // Input
            HStack {
                TextField("Message", text: $inputText)
                Button("Send") {
                    if let data = inputText.data(using: .utf8) {
                        mesh.broadcast(data)
                        inputText = ""
                    }
                }
            }
        }
        .onAppear {
            mesh.onMessageReceived = { data, peer in
                if let text = String(data: data, encoding: .utf8) {
                    messages.append((text, peer))
                }
            }
            mesh.startMesh()
        }
        .onDisappear {
            mesh.stopMesh()
        }
    }
}

Connection Tie-Breaker Strategy

To avoid duplicate connections (both peers trying to connect to each other), use a deterministic tie-breaker:

// Lower peer ID initiates the connection
if myPeerID < discoveredPeerID {
    initiateConnection(to: peer)
} else {
    // Wait for them to connect to us
}

This ensures:

  • Only ONE connection is created between any two peers
  • No race conditions or duplicate connections
  • Deterministic behavior across all devices

Mesh Network Limits

Devices Connections per Device Total Connections
2 1 1
3 2 3
4 3 6
5 4 10
6 5 15
7 6 21
8 7 28

Formula: Total connections = N × (N-1) / 2

Mesh Network Considerations

  1. Connection Overhead: Each device maintains N-1 connections. With 8 devices, that's 7 connections per device and 28 total connections in the network.

  2. Message Duplication: When broadcasting, each peer sends to all others. Ensure your app handles potential duplicate messages (use message IDs).

  3. Partial Mesh: For larger groups, consider a partial mesh where not everyone connects to everyone. Use relay nodes instead.

  4. Battery Impact: More connections = more battery usage. Consider reducing broadcast frequency or implementing sleep modes.

  5. Network Partition: Handle cases where the mesh splits (some devices can't reach others). Implement reconnection logic.

  6. Message Ordering: Messages may arrive out of order. Use timestamps or sequence numbers if order matters.

Message Relay (Optional)

For messages that need to reach peers you're not directly connected to:

func relayBroadcast(_ data: Data, originalSenderID: String, seenBy: Set<String>) {
    var updatedSeenBy = seenBy
    updatedSeenBy.insert(myPeerID)

    let message = MeshMessage(
        type: .relayedBroadcast(payload: data, originalSender: originalSenderID, seenBy: updatedSeenBy),
        senderID: myPeerID
    )

    guard let encoded = try? JSONEncoder().encode(message) else { return }

    // Forward to peers who haven't seen it
    for peerID in stablePeers where !updatedSeenBy.contains(peerID) {
        if let connection = connections[peerID] {
            NetworkFraming.send(encoded, over: connection) { _ in }
        }
    }
}

This allows messages to propagate through the entire mesh even if not all devices are directly connected.