1258 lines
38 KiB
Markdown
1258 lines
38 KiB
Markdown
# 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.
|