Files
AtTable/transition.md
2026-01-21 01:04:50 -05:00

1258 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```swift
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
```swift
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:
```swift
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:
```swift
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
```swift
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)
```swift
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)
```swift
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)
```swift
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)
```swift
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)
```swift
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):
```xml
<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:
```xml
<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:
```swift
// 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:
```swift
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:
```swift
// 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
```swift
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
```swift
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
```swift
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
```swift
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**:
```swift
// 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:
```swift
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.