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.
38 KiB
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
DiscoveredHostandConnectedPeermodels - Create
NetworkFramingutility for TCP framing - Create
P2PConnectionManagerwith NWListener/NWBrowser - (Optional) Create
BLEDiscoveryManagerfor fast discovery - Update Info.plist with BLE permissions (if using BLE)
- Update all views to use new manager
- Replace
MCPeerIDwithUUIDor custom struct - Implement traffic gate pattern (4-second stabilization)
- Implement connection retry with exponential backoff
- Add
onHostConnectedcallback 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
MultipeerManageras 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
-
Connection Overhead: Each device maintains N-1 connections. With 8 devices, that's 7 connections per device and 28 total connections in the network.
-
Message Duplication: When broadcasting, each peer sends to all others. Ensure your app handles potential duplicate messages (use message IDs).
-
Partial Mesh: For larger groups, consider a partial mesh where not everyone connects to everyone. Use relay nodes instead.
-
Battery Impact: More connections = more battery usage. Consider reducing broadcast frequency or implementing sleep modes.
-
Network Partition: Handle cases where the mesh splits (some devices can't reach others). Implement reconnection logic.
-
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.