diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..3aed851
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,10 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(git add:*)",
+ "Bash(git commit:*)",
+ "Bash(git push:*)",
+ "Bash(./check_build.sh:*)"
+ ]
+ }
+}
diff --git a/BeamScribe/BeamScribeApp.swift b/BeamScribe/BeamScribeApp.swift
index 845b2d9..fd775a3 100644
--- a/BeamScribe/BeamScribeApp.swift
+++ b/BeamScribe/BeamScribeApp.swift
@@ -11,19 +11,13 @@ import SwiftData
@main
struct BeamScribeApp: App {
@StateObject private var sessionState = SessionState()
- @StateObject private var transcriptionManager = TranscriptionManager()
- @StateObject private var multipeerManager = MultipeerManager()
- @StateObject private var fileManager = FileStorageManager()
@StateObject private var subscriptionManager = SubscriptionManager()
@Environment(\.scenePhase) private var scenePhase
-
+
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(sessionState)
- .environmentObject(transcriptionManager)
- .environmentObject(multipeerManager)
- .environmentObject(fileManager)
.environmentObject(subscriptionManager)
// Enforce light mode for now to ensure consistency
.preferredColorScheme(.light)
diff --git a/BeamScribe/ContentView.swift b/BeamScribe/ContentView.swift
index 069ace8..9da534f 100644
--- a/BeamScribe/ContentView.swift
+++ b/BeamScribe/ContentView.swift
@@ -7,14 +7,14 @@
import SwiftUI
import Combine
-import MultipeerConnectivity
+import Network
import AVFoundation
struct ContentView: View {
@EnvironmentObject var sessionState: SessionState
-
+
@StateObject private var transcriptionManager = TranscriptionManager()
- @StateObject private var multipeerManager = MultipeerManager()
+ @StateObject private var p2pManager = P2PConnectionManager()
@StateObject private var fileManager = FileStorageManager()
@StateObject private var audioManager = AudioSessionManager()
@StateObject private var settings = SettingsModel()
@@ -30,7 +30,7 @@ struct ContentView: View {
case .hostSetup:
HostSetupView(
transcriptionManager: transcriptionManager,
- multipeerManager: multipeerManager,
+ p2pManager: p2pManager,
fileManager: fileManager
)
.transition(.move(edge: .trailing))
@@ -44,7 +44,7 @@ struct ContentView: View {
case .guestBrowsing:
GuestBrowserView(
- multipeerManager: multipeerManager,
+ p2pManager: p2pManager,
fileManager: fileManager
)
.transition(.move(edge: .trailing))
@@ -52,7 +52,7 @@ struct ContentView: View {
case .activeSession:
TranscriptView(
transcriptionManager: transcriptionManager,
- multipeerManager: multipeerManager,
+ p2pManager: p2pManager,
fileManager: fileManager,
audioManager: audioManager
)
@@ -72,87 +72,85 @@ struct ContentView: View {
}
// MARK: - Setup
-
+
private func setupCallbacks() {
// Handle transcription results (Host)
transcriptionManager.onPartialResult = { [weak sessionState] text in
guard let sessionState = sessionState else { return }
-
+
// Broadcast partial result to guests (skip if solo mode)
if !sessionState.isSoloMode {
- multipeerManager.sendLiveChunk(
+ p2pManager.sendLiveChunk(
text: text,
eventName: sessionState.eventName,
isFinal: false
)
}
}
-
+
transcriptionManager.onFinalResult = { [weak sessionState] text in
guard let sessionState = sessionState else { return }
-
+
// Add to local state
Task { @MainActor in
sessionState.addSegment(TranscriptSegment(text: text, isFinal: true))
}
-
+
// Save to file
fileManager.appendText(text)
-
+
// Broadcast to guests (skip if solo mode)
if !sessionState.isSoloMode {
- multipeerManager.sendLiveChunk(
+ p2pManager.sendLiveChunk(
text: text,
eventName: sessionState.eventName,
isFinal: true
)
}
}
-
+
transcriptionManager.onSessionResumed = { [weak sessionState] in
guard let sessionState = sessionState else { return }
-
+
Task { @MainActor in
sessionState.insertResumedMarker()
}
-
+
// Broadcast resume marker to guests (skip if solo mode)
if !sessionState.isSoloMode {
- multipeerManager.sendAlert(
+ p2pManager.sendAlert(
type: .sessionResumed,
eventName: sessionState.eventName
)
}
-
+
// Also append to file
let formatter = DateFormatter()
formatter.timeStyle = .short
let marker = "[Session Resumed at \(formatter.string(from: Date()))]"
fileManager.appendMarker(marker)
}
-
+
// Handle received packets (Guest)
- multipeerManager.onPacketReceived = { [weak sessionState, weak transcriptionManager] packet in
+ p2pManager.onPacketReceived = { [weak sessionState, weak transcriptionManager] packet in
guard let sessionState = sessionState, let transcriptionManager = transcriptionManager else { return }
-
+
Task { @MainActor in
switch packet.type {
case .fullHistory:
if let text = packet.text {
sessionState.loadFullHistory(text)
-
+
// Sync start time from host (using relative duration to avoid clock skew)
if let duration = packet.currentSessionDuration {
let calculatedStartTime = Date().addingTimeInterval(-duration)
sessionState.startTime = calculatedStartTime
}
-
- // Cancel pending appends to avoid race conditions? No, just overwrite.
-
+
// Overwrite file to avoid duplication if full history is received multiple times
fileManager.overwriteCurrentFile(with: text, eventName: sessionState.eventName)
}
-
+
case .liveChunk:
if let text = packet.text {
let isFinal = packet.isFinal ?? true
@@ -165,7 +163,7 @@ struct ContentView: View {
sessionState.updateLastPartialSegment(text, isFinal: false)
}
}
-
+
case .alert:
switch packet.alertType {
case .hostDisconnected:
@@ -181,39 +179,52 @@ struct ContentView: View {
}
}
}
-
+
// Handle host lost (Guest)
- multipeerManager.onHostLost = { [weak sessionState, weak transcriptionManager] in
+ p2pManager.onHostLost = { [weak sessionState, weak transcriptionManager] in
guard let sessionState = sessionState, let transcriptionManager = transcriptionManager else { return }
-
+
Task { @MainActor in
sessionState.showHostLostBanner = true
sessionState.isConnectedToHost = false
-
+
// Freeze the timer
transcriptionManager.sessionEndTime = Date()
}
}
-
+
+ // Handle host connected (Guest) - clears "host lost" state on rejoin
+ p2pManager.onHostConnected = { [weak sessionState, weak transcriptionManager] in
+ guard let sessionState = sessionState, let transcriptionManager = transcriptionManager else { return }
+
+ Task { @MainActor in
+ sessionState.showHostLostBanner = false
+ sessionState.isConnectedToHost = true
+
+ // Resume the timer by clearing the end time
+ transcriptionManager.sessionEndTime = nil
+ }
+ }
+
// Handle peer count changes (UI update only)
- multipeerManager.onPeerCountChanged = { [weak sessionState] count in
+ p2pManager.onPeerCountChanged = { [weak sessionState] count in
guard let sessionState = sessionState else { return }
-
+
Task { @MainActor in
sessionState.connectedPeerCount = count
}
}
-
+
// Handle specific peer join (Send History)
- multipeerManager.onPeerJoined = { [weak sessionState] peerID in
+ p2pManager.onPeerJoined = { [weak sessionState] peerID in
guard let sessionState = sessionState else { return }
-
+
Task { @MainActor in
// If host, send full history to the new peer
if sessionState.userRole == .host {
- print("New peer joined: \(peerID.displayName). Sending full history.")
-
- multipeerManager.sendFullHistory(
+ print("New peer joined: \(peerID). Sending full history.")
+
+ p2pManager.sendFullHistory(
to: peerID,
text: sessionState.fullTranscriptText,
eventName: sessionState.eventName,
diff --git a/BeamScribe/Info.plist b/BeamScribe/Info.plist
index c4f7834..17209e5 100644
--- a/BeamScribe/Info.plist
+++ b/BeamScribe/Info.plist
@@ -12,6 +12,14 @@
UIBackgroundModes
audio
+ bluetooth-central
+ bluetooth-peripheral
+ NSBluetoothAlwaysUsageDescription
+ BeamScribe uses Bluetooth to quickly discover nearby sessions.
+ NSBluetoothPeripheralUsageDescription
+ BeamScribe uses Bluetooth to advertise your session to nearby devices.
+ NSLocalNetworkUsageDescription
+ BeamScribe uses the local network to connect hosts and guests for live transcription.
diff --git a/BeamScribe/Managers/BLEDiscoveryManager.swift b/BeamScribe/Managers/BLEDiscoveryManager.swift
new file mode 100644
index 0000000..7d96db4
--- /dev/null
+++ b/BeamScribe/Managers/BLEDiscoveryManager.swift
@@ -0,0 +1,211 @@
+//
+// BLEDiscoveryManager.swift
+// BeamScribe
+//
+// CoreBluetooth-based fast discovery layer.
+// BLE advertisement is near-instant and "wakes" the AWDL interface for faster
+// Network.framework discovery.
+//
+
+import Foundation
+import CoreBluetooth
+import Combine
+
+@MainActor
+class BLEDiscoveryManager: NSObject, ObservableObject {
+
+ // MARK: - Published Properties
+
+ @Published var isAdvertising: Bool = false
+ @Published var isScanning: Bool = false
+
+ // MARK: - Callbacks
+
+ /// Called when a BeamScribe host is discovered via BLE
+ var onHostDiscovered: ((String) -> Void)?
+
+ // MARK: - Private Properties
+
+ /// BeamScribe service UUID for BLE discovery
+ private let serviceUUID = CBUUID(string: "B34E5C01-BE00-4000-8000-000000000001")
+
+ /// Peripheral manager for advertising (host mode)
+ private var peripheralManager: CBPeripheralManager?
+
+ /// Central manager for scanning (guest mode)
+ private var centralManager: CBCentralManager?
+
+ /// Current event name being advertised
+ private var eventName: String = ""
+
+ /// Discovered peripheral IDs to prevent duplicate callbacks
+ private var discoveredPeripherals: Set = []
+
+ // MARK: - Initialization
+
+ override init() {
+ super.init()
+ }
+
+ // MARK: - Host Mode (Peripheral)
+
+ func startAdvertising(eventName: String) {
+ guard peripheralManager == nil else {
+ // Already advertising, just update event name
+ if isAdvertising {
+ stopAdvertising()
+ self.eventName = eventName
+ startAdvertising(eventName: eventName)
+ }
+ return
+ }
+
+ self.eventName = eventName
+ peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
+ // Advertising will start once peripheral manager is powered on
+ }
+
+ func stopAdvertising() {
+ peripheralManager?.stopAdvertising()
+ peripheralManager = nil
+ isAdvertising = false
+ }
+
+ private func beginAdvertising() {
+ guard let manager = peripheralManager, manager.state == .poweredOn else {
+ return
+ }
+
+ // BLE advertisement data has a 28-byte limit for local name
+ // Truncate event name to fit
+ let truncatedName = String(eventName.prefix(8))
+
+ let advertisementData: [String: Any] = [
+ CBAdvertisementDataServiceUUIDsKey: [serviceUUID],
+ CBAdvertisementDataLocalNameKey: truncatedName
+ ]
+
+ manager.startAdvertising(advertisementData)
+ isAdvertising = true
+
+ print("BLE advertising started for: \(truncatedName)")
+ }
+
+ // MARK: - Guest Mode (Central)
+
+ func startScanning() {
+ guard centralManager == nil else {
+ return
+ }
+
+ discoveredPeripherals.removeAll()
+ centralManager = CBCentralManager(delegate: self, queue: nil)
+ // Scanning will start once central manager is powered on
+ }
+
+ func stopScanning() {
+ centralManager?.stopScan()
+ centralManager = nil
+ isScanning = false
+ discoveredPeripherals.removeAll()
+ }
+
+ private func beginScanning() {
+ guard let manager = centralManager, manager.state == .poweredOn else {
+ return
+ }
+
+ // Scan specifically for BeamScribe service
+ manager.scanForPeripherals(
+ withServices: [serviceUUID],
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
+ )
+ isScanning = true
+
+ print("BLE scanning started")
+ }
+}
+
+// MARK: - CBPeripheralManagerDelegate
+
+extension BLEDiscoveryManager: CBPeripheralManagerDelegate {
+
+ nonisolated func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
+ Task { @MainActor in
+ switch peripheral.state {
+ case .poweredOn:
+ print("Peripheral manager powered on")
+ beginAdvertising()
+ case .poweredOff:
+ print("Bluetooth is powered off")
+ isAdvertising = false
+ case .unauthorized:
+ print("Bluetooth unauthorized")
+ isAdvertising = false
+ case .unsupported:
+ print("Bluetooth not supported")
+ isAdvertising = false
+ default:
+ break
+ }
+ }
+ }
+
+ nonisolated func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
+ Task { @MainActor in
+ if let error = error {
+ print("Failed to start BLE advertising: \(error)")
+ isAdvertising = false
+ } else {
+ print("BLE advertising started successfully")
+ isAdvertising = true
+ }
+ }
+ }
+}
+
+// MARK: - CBCentralManagerDelegate
+
+extension BLEDiscoveryManager: CBCentralManagerDelegate {
+
+ nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
+ Task { @MainActor in
+ switch central.state {
+ case .poweredOn:
+ print("Central manager powered on")
+ beginScanning()
+ case .poweredOff:
+ print("Bluetooth is powered off")
+ isScanning = false
+ case .unauthorized:
+ print("Bluetooth unauthorized")
+ isScanning = false
+ case .unsupported:
+ print("Bluetooth not supported")
+ isScanning = false
+ default:
+ break
+ }
+ }
+ }
+
+ nonisolated func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
+ Task { @MainActor in
+ // Avoid duplicate discoveries
+ guard !discoveredPeripherals.contains(peripheral.identifier) else {
+ return
+ }
+ discoveredPeripherals.insert(peripheral.identifier)
+
+ // Extract the advertised name (event name hint)
+ let hostName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
+ ?? peripheral.name
+ ?? "Unknown Host"
+
+ print("BLE discovered BeamScribe host: \(hostName) (RSSI: \(RSSI))")
+
+ // Notify delegate - this primes AWDL for faster Network.framework discovery
+ onHostDiscovered?(hostName)
+ }
+ }
+}
diff --git a/BeamScribe/Managers/MultipeerManager.swift b/BeamScribe/Managers/MultipeerManager.swift
index 245342e..6fe7897 100644
--- a/BeamScribe/Managers/MultipeerManager.swift
+++ b/BeamScribe/Managers/MultipeerManager.swift
@@ -363,9 +363,6 @@ extension MultipeerManager: MCSessionDelegate {
self.onPeerCountChanged?(self.connectedPeers.count)
case .notConnected:
- let wasConnected = self.connectedPeers.contains(peerID)
- let wasStable = self.stablePeers.contains(peerID)
-
// Check if this is an early disconnect (within unstable threshold)
var shouldRetry = false
if let connectedAt = self.connectedAtTimes[peerName] {
diff --git a/BeamScribe/Managers/NetworkFraming.swift b/BeamScribe/Managers/NetworkFraming.swift
new file mode 100644
index 0000000..5816ac9
--- /dev/null
+++ b/BeamScribe/Managers/NetworkFraming.swift
@@ -0,0 +1,159 @@
+//
+// NetworkFraming.swift
+// BeamScribe
+//
+// Length-prefixed TCP framing utilities for Network.framework.
+// Implements 4-byte big-endian length prefix for message framing.
+//
+
+import Foundation
+import Network
+
+/// Utilities for framing TCP messages with length prefixes
+enum NetworkFraming {
+ /// Header size in bytes (UInt32 = 4 bytes)
+ static let headerSize = 4
+
+ /// Maximum allowed message size (10 MB)
+ static let maxMessageSize: UInt32 = 10_485_760
+
+ /// Frame a packet for transmission by prepending a 4-byte length header
+ /// - Parameter packet: The TranscriptPacket to frame
+ /// - Returns: Framed data with length prefix, or nil if encoding fails
+ static func framePacket(_ packet: TranscriptPacket) -> Data? {
+ guard let payload = try? packet.encode() else {
+ return nil
+ }
+ return frameData(payload)
+ }
+
+ /// Frame raw data for transmission by prepending a 4-byte length header
+ /// - Parameter data: The data to frame
+ /// - Returns: Framed data with 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 a framed packet over an NWConnection
+ /// - Parameters:
+ /// - packet: The packet to send
+ /// - connection: The connection to send over
+ /// - completion: Called when send completes or fails
+ static func send(_ packet: TranscriptPacket, over connection: NWConnection, completion: @escaping (Error?) -> Void) {
+ guard let framedData = framePacket(packet) else {
+ completion(FramingError.encodingFailed)
+ return
+ }
+
+ connection.send(content: framedData, completion: .contentProcessed { error in
+ completion(error)
+ })
+ }
+
+ /// Receive a framed packet from an NWConnection
+ /// - Parameters:
+ /// - connection: The connection to receive from
+ /// - completion: Called with the decoded packet or error
+ static func receivePacket(from connection: NWConnection, completion: @escaping (Result) -> Void) {
+ // First, read the 4-byte length 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 {
+ if isComplete {
+ completion(.failure(FramingError.connectionClosed))
+ } else {
+ completion(.failure(FramingError.incompleteHeader))
+ }
+ return
+ }
+
+ // Parse the length from the header
+ let length = headerData.withUnsafeBytes { bytes in
+ bytes.load(as: UInt32.self).bigEndian
+ }
+
+ // Validate message size
+ guard length > 0, length <= maxMessageSize else {
+ completion(.failure(FramingError.invalidMessageSize(length)))
+ return
+ }
+
+ // Read the payload
+ connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { payloadData, _, isComplete, error in
+ if let error = error {
+ completion(.failure(error))
+ return
+ }
+
+ guard let payloadData = payloadData, payloadData.count == Int(length) else {
+ if isComplete {
+ completion(.failure(FramingError.connectionClosed))
+ } else {
+ completion(.failure(FramingError.incompletePayload))
+ }
+ return
+ }
+
+ // Decode the packet
+ do {
+ let packet = try TranscriptPacket.decode(from: payloadData)
+ completion(.success(packet))
+ } catch {
+ completion(.failure(FramingError.decodingFailed(error)))
+ }
+ }
+ }
+ }
+
+ /// Start a continuous receive loop for a connection
+ /// - Parameters:
+ /// - connection: The connection to receive from
+ /// - onPacket: Called for each received packet
+ /// - onError: Called if an error occurs (connection will stop receiving)
+ static func startReceiving(from connection: NWConnection, onPacket: @escaping (TranscriptPacket) -> Void, onError: @escaping (Error) -> Void) {
+ receivePacket(from: connection) { result in
+ switch result {
+ case .success(let packet):
+ onPacket(packet)
+ // Continue receiving
+ startReceiving(from: connection, onPacket: onPacket, onError: onError)
+ case .failure(let error):
+ onError(error)
+ }
+ }
+ }
+}
+
+/// Errors specific to network framing
+enum FramingError: LocalizedError {
+ case encodingFailed
+ case incompleteHeader
+ case incompletePayload
+ case invalidMessageSize(UInt32)
+ case decodingFailed(Error)
+ case connectionClosed
+
+ var errorDescription: String? {
+ switch self {
+ case .encodingFailed:
+ return "Failed to encode packet"
+ case .incompleteHeader:
+ return "Incomplete message header received"
+ case .incompletePayload:
+ return "Incomplete message payload received"
+ case .invalidMessageSize(let size):
+ return "Invalid message size: \(size) bytes"
+ case .decodingFailed(let error):
+ return "Failed to decode packet: \(error.localizedDescription)"
+ case .connectionClosed:
+ return "Connection closed unexpectedly"
+ }
+ }
+}
diff --git a/BeamScribe/Managers/P2PConnectionManager.swift b/BeamScribe/Managers/P2PConnectionManager.swift
new file mode 100644
index 0000000..29f9105
--- /dev/null
+++ b/BeamScribe/Managers/P2PConnectionManager.swift
@@ -0,0 +1,663 @@
+//
+// P2PConnectionManager.swift
+// BeamScribe
+//
+// Network.framework-based peer-to-peer connection manager.
+// Replaces MultipeerConnectivity with NWListener (host) and NWBrowser (guest).
+//
+
+import Foundation
+import Network
+import Combine
+import UIKit
+
+@MainActor
+class P2PConnectionManager: NSObject, ObservableObject {
+
+ // MARK: - Published Properties
+
+ /// Available hosts discovered via browsing (endpointID -> DiscoveredHost)
+ @Published var availableHosts: [String: DiscoveredHost] = [:]
+ /// Connected peers (for host: guests; for guest: just the host)
+ @Published var connectedPeers: [UUID: ConnectedPeer] = [:]
+ /// Whether currently hosting a session
+ @Published var isHosting: Bool = false
+ /// Whether currently browsing for hosts
+ @Published var isBrowsing: Bool = false
+ /// Whether connected to a host (guest mode)
+ @Published var isConnectedToHost: Bool = false
+ /// Current connection status string for UI
+ @Published var connectionStatus: String = ""
+
+ // MARK: - Callbacks
+
+ /// Called when a packet is received
+ var onPacketReceived: ((TranscriptPacket) -> Void)?
+ /// Called when peer count changes
+ var onPeerCountChanged: ((Int) -> Void)?
+ /// Called when a new peer joins and stabilizes (host sends history)
+ var onPeerJoined: ((UUID) -> Void)?
+ /// Called when host connection is lost (guest mode)
+ var onHostLost: (() -> Void)?
+ /// Called when successfully connected to host (guest mode) - use to clear "host lost" state
+ var onHostConnected: (() -> Void)?
+ /// Called when connection fails after all retries
+ var onConnectionFailed: ((DiscoveredHost) -> Void)?
+
+ // MARK: - Private Properties
+
+ private let serviceType = "_beamscribe._tcp"
+ private var eventName: String = ""
+
+ // Host mode
+ private var listener: NWListener?
+ private var guestConnections: [UUID: NWConnection] = [:]
+ private var stablePeers: Set = []
+
+ // Guest mode
+ private var browser: NWBrowser?
+ private var hostConnection: NWConnection?
+ private var currentHost: DiscoveredHost?
+
+ // BLE Discovery integration
+ private var bleDiscoveryManager: BLEDiscoveryManager?
+
+ // MARK: - Connection Configuration
+
+ /// Timeout for connection attempts
+ private let connectionTimeout: TimeInterval = 30
+ /// Delay before considering a peer stable (traffic gate)
+ private let stabilizationDelay: UInt64 = 4_000_000_000 // 4 seconds
+ /// Maximum retry attempts for failed connections
+ private let maxRetryAttempts = 3
+ /// Time threshold to consider connection unstable if it drops
+ private let unstableConnectionThreshold: TimeInterval = 5.0
+
+ // MARK: - Connection Retry State
+
+ private var connectionAttempts: [String: Int] = [:] // endpointID -> attempt count
+ private var connectionStartTimes: [String: Date] = [:]
+ private var connectedAtTimes: [String: Date] = [:]
+ private var pendingRetry: [String: Bool] = [:]
+ private var connectionTimeoutTask: Task?
+
+ // MARK: - Initialization
+
+ override init() {
+ super.init()
+ setupBLEDiscovery()
+ }
+
+ private func setupBLEDiscovery() {
+ bleDiscoveryManager = BLEDiscoveryManager()
+ bleDiscoveryManager?.onHostDiscovered = { hostName in
+ // BLE discovery primes AWDL, no action needed here
+ // The browser will find the host shortly after
+ print("BLE discovered host hint: \(hostName)")
+ }
+ }
+
+ // MARK: - Network Parameters
+
+ private func createNetworkParameters() -> NWParameters {
+ let parameters = NWParameters.tcp
+ parameters.includePeerToPeer = true
+ parameters.serviceClass = .responsiveData
+
+ // Allow local network
+ let options = NWProtocolTCP.Options()
+ options.enableKeepalive = true
+ options.keepaliveInterval = 30
+ parameters.defaultProtocolStack.transportProtocol = options
+
+ return parameters
+ }
+
+ // MARK: - Host Mode
+
+ func startHosting(eventName: String) {
+ stopHosting()
+
+ self.eventName = eventName
+ print("Starting hosting for event: \(eventName)")
+
+ do {
+ let parameters = createNetworkParameters()
+ listener = try NWListener(using: parameters)
+
+ // Configure Bonjour service advertisement with device name in TXT record
+ let deviceName = UIDevice.current.name
+ // Create TXT record data manually: length-prefixed key=value format
+ 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: serviceType,
+ txtRecord: txtData
+ )
+
+ listener?.stateUpdateHandler = { [weak self] state in
+ guard let self else { return }
+ Task { @MainActor in
+ self.handleListenerStateUpdate(state)
+ }
+ }
+
+ listener?.newConnectionHandler = { [weak self] connection in
+ guard let self else { return }
+ Task { @MainActor in
+ self.handleNewGuestConnection(connection)
+ }
+ }
+
+ listener?.start(queue: .main)
+ isHosting = true
+
+ // Start BLE advertising for fast discovery
+ bleDiscoveryManager?.startAdvertising(eventName: eventName)
+
+ } catch {
+ print("Failed to start listener: \(error)")
+ connectionStatus = "Failed to start hosting: \(error.localizedDescription)"
+ }
+ }
+
+ func stopHosting() {
+ print("Stopping hosting")
+ listener?.cancel()
+ listener = nil
+
+ // Close all guest connections
+ for (_, connection) in guestConnections {
+ connection.cancel()
+ }
+ guestConnections.removeAll()
+ connectedPeers.removeAll()
+ stablePeers.removeAll()
+
+ isHosting = false
+
+ bleDiscoveryManager?.stopAdvertising()
+ }
+
+ private func handleListenerStateUpdate(_ state: NWListener.State) {
+ switch state {
+ case .ready:
+ print("Listener ready, advertising as: \(eventName)")
+ connectionStatus = "Hosting: \(eventName)"
+ case .failed(let error):
+ print("Listener failed: \(error)")
+ connectionStatus = "Hosting failed: \(error.localizedDescription)"
+ isHosting = false
+ case .cancelled:
+ print("Listener cancelled")
+ isHosting = false
+ default:
+ break
+ }
+ }
+
+ private func handleNewGuestConnection(_ connection: NWConnection) {
+ let peerID = UUID()
+ let peerName = connection.endpoint.debugDescription
+
+ print("New guest connection from: \(peerName)")
+
+ guestConnections[peerID] = connection
+ let peer = ConnectedPeer(displayName: peerName, isStable: false)
+ connectedPeers[peerID] = peer
+ connectedAtTimes[peerID.uuidString] = Date()
+
+ connection.stateUpdateHandler = { [weak self] state in
+ guard let self else { return }
+ Task { @MainActor in
+ self.handleGuestConnectionStateUpdate(state, peerID: peerID, peerName: peerName)
+ }
+ }
+
+ connection.start(queue: .main)
+
+ // Start receiving data from this guest
+ NetworkFraming.startReceiving(from: connection) { [weak self] packet in
+ Task { @MainActor in
+ self?.onPacketReceived?(packet)
+ }
+ } onError: { [weak self] error in
+ Task { @MainActor in
+ print("Error receiving from guest \(peerName): \(error)")
+ self?.removeGuestConnection(peerID: peerID)
+ }
+ }
+
+ onPeerCountChanged?(connectedPeers.count)
+
+ // Start stabilization timer
+ startStabilizationTimer(for: peerID)
+ }
+
+ private func handleGuestConnectionStateUpdate(_ state: NWConnection.State, peerID: UUID, peerName: String) {
+ switch state {
+ case .ready:
+ print("Guest \(peerName) connection ready")
+ case .failed(let error):
+ print("Guest \(peerName) connection failed: \(error)")
+ removeGuestConnection(peerID: peerID)
+ case .cancelled:
+ print("Guest \(peerName) connection cancelled")
+ removeGuestConnection(peerID: peerID)
+ default:
+ break
+ }
+ }
+
+ private func removeGuestConnection(peerID: UUID) {
+ guestConnections[peerID]?.cancel()
+ guestConnections.removeValue(forKey: peerID)
+ connectedPeers.removeValue(forKey: peerID)
+ stablePeers.remove(peerID)
+ connectedAtTimes.removeValue(forKey: peerID.uuidString)
+ onPeerCountChanged?(connectedPeers.count)
+ }
+
+ private func startStabilizationTimer(for peerID: UUID) {
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: stabilizationDelay)
+
+ guard connectedPeers[peerID] != nil else { return }
+
+ stablePeers.insert(peerID)
+ connectedPeers[peerID]?.isStable = true
+ onPeerJoined?(peerID)
+
+ print("Peer \(peerID) is now stable")
+ }
+ }
+
+ // MARK: - Guest Mode
+
+ func startBrowsing() {
+ stopBrowsing()
+
+ print("Starting browsing")
+ availableHosts.removeAll()
+
+ let parameters = createNetworkParameters()
+ browser = NWBrowser(for: .bonjour(type: serviceType, domain: nil), using: parameters)
+
+ browser?.stateUpdateHandler = { [weak self] state in
+ guard let self else { return }
+ Task { @MainActor in
+ self.handleBrowserStateUpdate(state)
+ }
+ }
+
+ browser?.browseResultsChangedHandler = { [weak self] results, changes in
+ guard let self else { return }
+ Task { @MainActor in
+ self.handleBrowseResultsChanged(results: results, changes: changes)
+ }
+ }
+
+ browser?.start(queue: .main)
+ isBrowsing = true
+
+ // Start BLE scanning for fast discovery
+ bleDiscoveryManager?.startScanning()
+ }
+
+ func stopBrowsing() {
+ browser?.cancel()
+ browser = nil
+ isBrowsing = false
+
+ bleDiscoveryManager?.stopScanning()
+ }
+
+ private func handleBrowserStateUpdate(_ state: NWBrowser.State) {
+ switch state {
+ case .ready:
+ print("Browser ready")
+ connectionStatus = "Searching for hosts..."
+ case .failed(let error):
+ print("Browser failed: \(error)")
+ connectionStatus = "Search failed: \(error.localizedDescription)"
+ case .cancelled:
+ print("Browser cancelled")
+ default:
+ break
+ }
+ }
+
+ private func handleBrowseResultsChanged(results: Set, changes: Set) {
+ for change in changes {
+ switch change {
+ case .added(let result):
+ handleHostFound(result)
+ case .removed(let result):
+ handleHostLost(result)
+ case .changed(old: _, new: let newResult, flags: _):
+ // Update the host info
+ handleHostFound(newResult)
+ case .identical:
+ break
+ @unknown default:
+ break
+ }
+ }
+ }
+
+ private func handleHostFound(_ result: NWBrowser.Result) {
+ guard case .service(let name, let type, let domain, _) = result.endpoint else {
+ return
+ }
+
+ let endpointID = "\(name).\(type).\(domain)"
+ let deviceName = extractDeviceName(from: result)
+
+ print("Found host: \(name) on \(deviceName)")
+
+ // Remove any existing entry with same device name (host might have restarted)
+ availableHosts = availableHosts.filter { $0.value.deviceName != deviceName }
+
+ let host = DiscoveredHost(
+ deviceName: deviceName,
+ eventName: name,
+ endpointID: endpointID
+ )
+
+ availableHosts[endpointID] = host
+ }
+
+ private func handleHostLost(_ result: NWBrowser.Result) {
+ guard case .service(let name, let type, let domain, _) = result.endpoint else {
+ return
+ }
+
+ let endpointID = "\(name).\(type).\(domain)"
+ print("Lost host: \(name)")
+ availableHosts.removeValue(forKey: endpointID)
+ }
+
+ private func extractDeviceName(from result: NWBrowser.Result) -> String {
+ // Extract device name from TXT record metadata
+ if case .bonjour(let txtRecord) = result.metadata {
+ if let deviceName = txtRecord["deviceName"] {
+ return deviceName
+ }
+ }
+ // Fallback: use endpoint description
+ return result.endpoint.debugDescription
+ }
+
+ func joinHost(_ host: DiscoveredHost) {
+ let attempt = (connectionAttempts[host.endpointID] ?? 0) + 1
+ connectionAttempts[host.endpointID] = attempt
+
+ connectionStatus = "Joining \(host.eventName) (attempt \(attempt)/\(maxRetryAttempts))..."
+ currentHost = host
+ connectionStartTimes[host.endpointID] = Date()
+
+ // Create connection to host
+ let endpoint = NWEndpoint.service(
+ name: host.eventName,
+ type: serviceType,
+ 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.handleHostConnectionStateUpdate(state, host: host)
+ }
+ }
+
+ hostConnection?.start(queue: .main)
+
+ // Start connection watchdog
+ startConnectionWatchdog(for: host, attempt: attempt)
+ }
+
+ private func handleHostConnectionStateUpdate(_ state: NWConnection.State, host: DiscoveredHost) {
+ switch state {
+ case .ready:
+ cancelConnectionWatchdog()
+ connectionStatus = "Connected to \(host.eventName)!"
+ connectedAtTimes[host.endpointID] = Date()
+ pendingRetry[host.endpointID] = false
+
+ isConnectedToHost = true
+
+ // Notify that we're connected (clears "host lost" state on rejoin)
+ onHostConnected?()
+
+ // Create peer entry for the host
+ let peerID = UUID()
+ let peer = ConnectedPeer(displayName: host.deviceName, isStable: true)
+ connectedPeers[peerID] = peer
+
+ // Start receiving data from host
+ if let connection = hostConnection {
+ NetworkFraming.startReceiving(from: connection) { [weak self] packet in
+ Task { @MainActor in
+ self?.onPacketReceived?(packet)
+ }
+ } onError: { [weak self] error in
+ Task { @MainActor in
+ print("Error receiving from host: \(error)")
+ self?.handleHostDisconnect(host: host)
+ }
+ }
+ }
+
+ onPeerCountChanged?(1)
+
+ // Stop BLE scanning - we're connected
+ bleDiscoveryManager?.stopScanning()
+
+ case .failed(let error):
+ print("Host connection failed: \(error)")
+ handleHostConnectionFailure(host: host, error: error)
+
+ case .cancelled:
+ print("Host connection cancelled")
+ if isConnectedToHost {
+ handleHostDisconnect(host: host)
+ }
+
+ case .waiting(let error):
+ print("Host connection waiting: \(error)")
+ connectionStatus = "Waiting for host response..."
+
+ default:
+ break
+ }
+ }
+
+ private func handleHostConnectionFailure(host: DiscoveredHost, error: NWError) {
+ cancelConnectionWatchdog()
+
+ // Check if this was an early disconnect
+ var shouldRetry = false
+ if let connectedAt = connectedAtTimes[host.endpointID] {
+ let duration = Date().timeIntervalSince(connectedAt)
+ if duration < unstableConnectionThreshold {
+ shouldRetry = true
+ }
+ } else {
+ // Never connected successfully
+ shouldRetry = true
+ }
+
+ if shouldRetry && pendingRetry[host.endpointID] != true {
+ retryJoinHost(host)
+ } else if !shouldRetry {
+ isConnectedToHost = false
+ connectedPeers.removeAll()
+ onHostLost?()
+ }
+ }
+
+ private func handleHostDisconnect(host: DiscoveredHost) {
+ isConnectedToHost = false
+ connectedPeers.removeAll()
+ hostConnection?.cancel()
+ hostConnection = nil
+ currentHost = nil
+ onHostLost?()
+
+ // Resume BLE scanning for reconnection
+ bleDiscoveryManager?.startScanning()
+ }
+
+ private func startConnectionWatchdog(for host: DiscoveredHost, attempt: Int) {
+ connectionTimeoutTask?.cancel()
+
+ let watchdogTimeout = connectionTimeout + 5
+
+ connectionTimeoutTask = Task { @MainActor in
+ for elapsed in stride(from: 5, through: Int(watchdogTimeout), by: 5) {
+ try? await Task.sleep(nanoseconds: 5_000_000_000)
+
+ guard !Task.isCancelled else { return }
+ guard currentHost?.endpointID == host.endpointID else { return }
+ guard !isConnectedToHost else {
+ connectionStatus = "Connected!"
+ return
+ }
+
+ connectionStatus = "Waiting for response... (\(elapsed)s)"
+ }
+
+ guard !Task.isCancelled else { return }
+ guard currentHost?.endpointID == host.endpointID else { return }
+ guard !isConnectedToHost else { return }
+
+ connectionStatus = "Connection timed out, retrying..."
+ retryJoinHost(host)
+ }
+ }
+
+ private func cancelConnectionWatchdog() {
+ connectionTimeoutTask?.cancel()
+ connectionTimeoutTask = nil
+ }
+
+ private func retryJoinHost(_ host: DiscoveredHost) {
+ let attempts = connectionAttempts[host.endpointID] ?? 0
+
+ guard attempts < maxRetryAttempts else {
+ pendingRetry[host.endpointID] = false
+ onConnectionFailed?(host)
+ return
+ }
+
+ // Exponential backoff: 1s, 2s, 4s
+ let backoffSeconds = pow(2.0, Double(attempts - 1))
+ pendingRetry[host.endpointID] = true
+
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: UInt64(backoffSeconds * 1_000_000_000))
+
+ guard pendingRetry[host.endpointID] == true else { return }
+ guard currentHost?.endpointID == host.endpointID else { return }
+
+ // Check if host is still available
+ if let currentHostInfo = availableHosts[host.endpointID] {
+ joinHost(currentHostInfo)
+ } else {
+ pendingRetry[host.endpointID] = false
+ onConnectionFailed?(host)
+ }
+ }
+ }
+
+ func cancelRetry(for endpointID: String) {
+ pendingRetry[endpointID] = false
+ connectionAttempts[endpointID] = 0
+ }
+
+ // MARK: - Data Transmission
+
+ func broadcastPacket(_ packet: TranscriptPacket) {
+ // Traffic Gate: Only send to stable peers
+ let stableConnections = guestConnections.filter { stablePeers.contains($0.key) }
+ guard !stableConnections.isEmpty else { return }
+
+ for (peerID, connection) in stableConnections {
+ NetworkFraming.send(packet, over: connection) { error in
+ if let error = error {
+ print("Failed to send to peer \(peerID): \(error)")
+ }
+ }
+ }
+ }
+
+ func sendFullHistory(to peerID: UUID, text: String, eventName: String, startTime: Date?) {
+ guard let connection = guestConnections[peerID], stablePeers.contains(peerID) else {
+ return
+ }
+
+ var duration: TimeInterval? = nil
+ if let start = startTime {
+ duration = Date().timeIntervalSince(start)
+ }
+
+ let packet = TranscriptPacket(
+ type: .fullHistory,
+ text: text,
+ eventName: eventName,
+ currentSessionDuration: duration
+ )
+
+ NetworkFraming.send(packet, over: connection) { error in
+ if let error = error {
+ print("Failed to send full history: \(error)")
+ }
+ }
+ }
+
+ func sendLiveChunk(text: String, eventName: String, isFinal: Bool) {
+ let packet = TranscriptPacket(
+ type: .liveChunk,
+ text: text,
+ eventName: eventName,
+ isFinal: isFinal
+ )
+ broadcastPacket(packet)
+ }
+
+ func sendAlert(type: AlertType, eventName: String) {
+ let packet = TranscriptPacket(
+ type: .alert,
+ text: nil,
+ eventName: eventName,
+ alertType: type
+ )
+ broadcastPacket(packet)
+ }
+
+ // MARK: - Disconnect
+
+ func disconnect() {
+ print("Disconnecting all peers")
+ cancelConnectionWatchdog()
+ stopHosting()
+ stopBrowsing()
+
+ hostConnection?.cancel()
+ hostConnection = nil
+
+ connectedPeers.removeAll()
+ availableHosts.removeAll()
+ isConnectedToHost = false
+ currentHost = nil
+ connectionStatus = ""
+ }
+}
diff --git a/BeamScribe/Models/ConnectedPeer.swift b/BeamScribe/Models/ConnectedPeer.swift
new file mode 100644
index 0000000..836a640
--- /dev/null
+++ b/BeamScribe/Models/ConnectedPeer.swift
@@ -0,0 +1,47 @@
+//
+// ConnectedPeer.swift
+// BeamScribe
+//
+// Represents a connected peer for Network.framework-based connections.
+//
+
+import Foundation
+import Network
+
+/// Represents a connected peer (guest from host's perspective, or host from guest's perspective)
+struct ConnectedPeer: Identifiable, Hashable {
+ /// Unique identifier for this peer
+ let id: UUID
+ /// Display name of the peer device
+ let displayName: String
+ /// Timestamp when connection was established
+ let connectedAt: Date
+ /// Whether the peer has been stable long enough to receive data
+ var isStable: Bool
+
+ init(displayName: String, isStable: Bool = false) {
+ self.id = UUID()
+ self.displayName = displayName
+ self.connectedAt = Date()
+ self.isStable = isStable
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+
+ static func == (lhs: ConnectedPeer, rhs: ConnectedPeer) -> Bool {
+ lhs.id == rhs.id
+ }
+}
+
+/// Connection state for the P2P manager
+enum P2PConnectionState {
+ case idle
+ case hosting
+ case browsing
+ case connecting
+ case connected
+ case disconnected
+ case failed(Error)
+}
diff --git a/BeamScribe/Models/DiscoveredHost.swift b/BeamScribe/Models/DiscoveredHost.swift
new file mode 100644
index 0000000..760fb07
--- /dev/null
+++ b/BeamScribe/Models/DiscoveredHost.swift
@@ -0,0 +1,38 @@
+//
+// DiscoveredHost.swift
+// BeamScribe
+//
+// Represents a discovered host for Network.framework-based discovery.
+//
+
+import Foundation
+
+/// Represents a host discovered via NWBrowser or BLE
+struct DiscoveredHost: Identifiable, Hashable {
+ /// Unique identifier for this host discovery
+ let id: UUID
+ /// The device name of the host
+ let deviceName: String
+ /// The event name being hosted
+ let eventName: String
+ /// Timestamp when discovered
+ let discoveredAt: Date
+ /// The endpoint identifier from NWBrowser (used for connection)
+ let endpointID: String
+
+ 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
+ }
+}
diff --git a/BeamScribe/Views/GuestBrowserView.swift b/BeamScribe/Views/GuestBrowserView.swift
index 00ce2bf..9576582 100644
--- a/BeamScribe/Views/GuestBrowserView.swift
+++ b/BeamScribe/Views/GuestBrowserView.swift
@@ -6,15 +6,15 @@
//
import SwiftUI
-import MultipeerConnectivity
+import Network
struct GuestBrowserView: View {
@EnvironmentObject var sessionState: SessionState
- @ObservedObject var multipeerManager: MultipeerManager
+ @ObservedObject var p2pManager: P2PConnectionManager
@ObservedObject var fileManager: FileStorageManager
-
+
@State private var isConnecting: Bool = false
- @State private var selectedHost: MCPeerID?
+ @State private var selectedHost: DiscoveredHost?
@State private var connectionFailed: Bool = false
var body: some View {
@@ -61,35 +61,35 @@ struct GuestBrowserView: View {
}
// Host List
- if multipeerManager.availableHosts.isEmpty {
+ if p2pManager.availableHosts.isEmpty {
Spacer()
-
+
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.5)
-
+
Text("Searching for active sessions")
.font(.callout)
.foregroundColor(.secondary)
-
+
Text("Make sure the host has started transcribing")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
-
+
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 12) {
- ForEach(Array(multipeerManager.availableHosts.keys), id: \.self) { peerID in
+ ForEach(Array(p2pManager.availableHosts.values), id: \.id) { host in
HostRow(
- eventName: multipeerManager.availableHosts[peerID] ?? "Unknown",
- deviceName: peerID.displayName,
- isConnecting: isConnecting && selectedHost == peerID,
- statusMessage: selectedHost == peerID ? multipeerManager.connectionStatus : ""
+ eventName: host.eventName,
+ deviceName: host.deviceName,
+ isConnecting: isConnecting && selectedHost?.id == host.id,
+ statusMessage: selectedHost?.id == host.id ? p2pManager.connectionStatus : ""
) {
- joinHost(peerID)
+ joinHost(host)
}
}
}
@@ -101,9 +101,9 @@ struct GuestBrowserView: View {
Button(action: {
// Cancel any pending retries
if let host = selectedHost {
- multipeerManager.cancelRetry(for: host.displayName)
+ p2pManager.cancelRetry(for: host.endpointID)
}
- multipeerManager.stopBrowsing()
+ p2pManager.stopBrowsing()
withAnimation {
sessionState.appPhase = .roleSelection
}
@@ -116,45 +116,44 @@ struct GuestBrowserView: View {
}
}
.onAppear {
- multipeerManager.startBrowsing()
+ p2pManager.startBrowsing()
setupConnectionFailedHandler()
}
.onDisappear {
if sessionState.appPhase != .activeSession {
- multipeerManager.stopBrowsing()
+ p2pManager.stopBrowsing()
}
}
- .onChange(of: multipeerManager.isConnectedToHost) { oldValue, newValue in
- if newValue, let host = selectedHost,
- let eventName = multipeerManager.availableHosts[host] {
+ .onChange(of: p2pManager.isConnectedToHost) { oldValue, newValue in
+ if newValue, let host = selectedHost {
// Connected successfully
// Note: We do NOT call stopBrowsing() here anymore because it kills the session.
- // The MultipeerManager handles the radio shutdown safely after stabilization.
-
+ // The P2PConnectionManager handles the radio shutdown safely after stabilization.
+
// Create a transcript file for the guest immediately
- _ = try? fileManager.createTranscriptFile(eventName: eventName)
-
- sessionState.joinGuestSession(eventName: eventName)
-
+ _ = try? fileManager.createTranscriptFile(eventName: host.eventName)
+
+ sessionState.joinGuestSession(eventName: host.eventName)
+
withAnimation {
sessionState.appPhase = .activeSession
}
}
}
}
-
- private func joinHost(_ peerID: MCPeerID) {
- selectedHost = peerID
+
+ private func joinHost(_ host: DiscoveredHost) {
+ selectedHost = host
isConnecting = true
connectionFailed = false
-
- multipeerManager.joinHost(peerID)
+
+ p2pManager.joinHost(host)
}
-
+
private func setupConnectionFailedHandler() {
- multipeerManager.onConnectionFailed = { [self] failedPeer in
+ p2pManager.onConnectionFailed = { [self] failedHost in
Task { @MainActor in
- if failedPeer == selectedHost {
+ if failedHost.id == selectedHost?.id {
isConnecting = false
connectionFailed = true
}
@@ -227,7 +226,7 @@ struct HostRow: View {
#Preview {
GuestBrowserView(
- multipeerManager: MultipeerManager(),
+ p2pManager: P2PConnectionManager(),
fileManager: FileStorageManager()
)
.environmentObject(SessionState())
diff --git a/BeamScribe/Views/HostSetupView.swift b/BeamScribe/Views/HostSetupView.swift
index a3c827d..0c923a8 100644
--- a/BeamScribe/Views/HostSetupView.swift
+++ b/BeamScribe/Views/HostSetupView.swift
@@ -10,7 +10,7 @@ import SwiftUI
struct HostSetupView: View {
@EnvironmentObject var sessionState: SessionState
@ObservedObject var transcriptionManager: TranscriptionManager
- @ObservedObject var multipeerManager: MultipeerManager
+ @ObservedObject var p2pManager: P2PConnectionManager
@ObservedObject var fileManager: FileStorageManager
@State private var eventName: String = ""
@@ -181,7 +181,7 @@ struct HostSetupView: View {
}
// Start hosting (advertising)
- multipeerManager.startHosting(eventName: eventName)
+ p2pManager.startHosting(eventName: eventName)
// Start transcription
do {
@@ -207,7 +207,7 @@ struct HostSetupView: View {
#Preview {
HostSetupView(
transcriptionManager: TranscriptionManager(),
- multipeerManager: MultipeerManager(),
+ p2pManager: P2PConnectionManager(),
fileManager: FileStorageManager()
)
.environmentObject(SessionState())
diff --git a/BeamScribe/Views/SettingsView.swift b/BeamScribe/Views/SettingsView.swift
index 6633573..62a1f18 100644
--- a/BeamScribe/Views/SettingsView.swift
+++ b/BeamScribe/Views/SettingsView.swift
@@ -11,7 +11,7 @@ import UIKit
struct SettingsView: View {
@EnvironmentObject var sessionState: SessionState
@ObservedObject var fileManager: FileStorageManager
- @ObservedObject var multipeerManager: MultipeerManager
+ @ObservedObject var p2pManager: P2PConnectionManager
@ObservedObject var transcriptionManager: TranscriptionManager
@EnvironmentObject var settings: SettingsModel
@@ -50,7 +50,7 @@ struct SettingsView: View {
HStack {
Label("Listeners", systemImage: "person.2.fill")
Spacer()
- Text("\(multipeerManager.connectedPeers.count)")
+ Text("\(p2pManager.connectedPeers.count)")
.foregroundColor(.secondary)
}
}
@@ -202,7 +202,7 @@ struct SettingsView: View {
}
// Disconnect networking
- multipeerManager.disconnect()
+ p2pManager.disconnect()
// Clear session info
fileManager.clearSessionInfo()
@@ -232,7 +232,7 @@ struct ShareSheet: UIViewControllerRepresentable {
#Preview {
SettingsView(
fileManager: FileStorageManager(),
- multipeerManager: MultipeerManager(),
+ p2pManager: P2PConnectionManager(),
transcriptionManager: TranscriptionManager()
)
.environmentObject(SessionState())
diff --git a/BeamScribe/Views/TranscriptView.swift b/BeamScribe/Views/TranscriptView.swift
index f7fae1e..93a3ded 100644
--- a/BeamScribe/Views/TranscriptView.swift
+++ b/BeamScribe/Views/TranscriptView.swift
@@ -10,7 +10,7 @@ import SwiftUI
struct TranscriptView: View {
@EnvironmentObject var sessionState: SessionState
@ObservedObject var transcriptionManager: TranscriptionManager
- @ObservedObject var multipeerManager: MultipeerManager
+ @ObservedObject var p2pManager: P2PConnectionManager
@ObservedObject var fileManager: FileStorageManager
@ObservedObject var audioManager: AudioSessionManager
@@ -113,7 +113,7 @@ struct TranscriptView: View {
.sheet(isPresented: $sessionState.showSettings) {
SettingsView(
fileManager: fileManager,
- multipeerManager: multipeerManager,
+ p2pManager: p2pManager,
transcriptionManager: transcriptionManager
)
}
@@ -168,17 +168,17 @@ struct TranscriptView: View {
.font(.caption)
.foregroundColor(.secondary)
} else {
- Text("\(multipeerManager.connectedPeers.count) listener\(multipeerManager.connectedPeers.count == 1 ? "" : "s") connected")
+ Text("\(p2pManager.connectedPeers.count) listener\(p2pManager.connectedPeers.count == 1 ? "" : "s") connected")
.font(.caption)
.foregroundColor(.secondary)
}
} else {
HStack(spacing: 4) {
Circle()
- .fill(multipeerManager.isConnectedToHost ? Color.green : Color.red)
+ .fill(p2pManager.isConnectedToHost ? Color.green : Color.red)
.frame(width: 8, height: 8)
-
- Text(multipeerManager.isConnectedToHost ? "Connected" : "Disconnected")
+
+ Text(p2pManager.isConnectedToHost ? "Connected" : "Disconnected")
.font(.caption)
.foregroundColor(.secondary)
}
@@ -352,7 +352,7 @@ struct TranscriptView: View {
}
// Disconnect networking
- multipeerManager.disconnect()
+ p2pManager.disconnect()
// Clear session info
fileManager.clearSessionInfo()
@@ -369,8 +369,8 @@ struct TranscriptView: View {
Image(systemName: "timer")
.foregroundColor(settings.textColor)
- let isTimerRunning = transcriptionManager.isTranscribing ||
- (sessionState.userRole == .guest && multipeerManager.isConnectedToHost)
+ let isTimerRunning = transcriptionManager.isTranscribing ||
+ (sessionState.userRole == .guest && p2pManager.isConnectedToHost)
if isTimerRunning {
TimelineView(.periodic(from: .now, by: 1.0)) { context in
@@ -412,7 +412,7 @@ struct TranscriptView: View {
#Preview {
TranscriptView(
transcriptionManager: TranscriptionManager(),
- multipeerManager: MultipeerManager(),
+ p2pManager: P2PConnectionManager(),
fileManager: FileStorageManager(),
audioManager: AudioSessionManager()
)
diff --git a/IMPLEMENTATION_PLAN_NETWORK_FRAMEWORK.md b/IMPLEMENTATION_PLAN_NETWORK_FRAMEWORK.md
new file mode 100644
index 0000000..2e00d9f
--- /dev/null
+++ b/IMPLEMENTATION_PLAN_NETWORK_FRAMEWORK.md
@@ -0,0 +1,243 @@
+# BeamScribe: MPC to Network.framework Transition Plan
+
+## Overview
+Transition from MultipeerConnectivity to Network.framework while preserving all existing functionality: live transcription broadcast, late-joiner history sync, alerts, and multi-guest support.
+
+---
+
+## Phase 1: Create P2PConnectionManager (Network.framework Core)
+
+### 1.1 Define the Protocol Interface
+Create a protocol that mirrors what `MultipeerManager` currently provides to the UI:
+
+```swift
+protocol P2PConnectionDelegate: AnyObject {
+ func didDiscoverHost(_ host: DiscoveredHost)
+ func didLoseHost(_ host: DiscoveredHost)
+ func didConnectGuest(_ guest: ConnectedPeer)
+ func didDisconnectGuest(_ guest: ConnectedPeer)
+ func didReceiveData(_ data: Data, from peer: ConnectedPeer)
+ func connectionStateChanged(_ state: P2PConnectionState)
+}
+```
+
+### 1.2 Host Side: NWListener
+Replace `MCNearbyServiceAdvertiser` with `NWListener`:
+
+```swift
+// Key configuration
+let parameters = NWParameters.tcp
+parameters.includePeerToPeer = true // Enables AWDL
+parameters.serviceClass = .responsiveData // Low-latency priority
+
+// Advertise with service type
+let listener = try NWListener(using: parameters)
+listener.service = NWListener.Service(
+ name: eventName, // Your event name (currently in discoveryInfo)
+ type: "_beamscribe._tcp"
+)
+```
+
+**Connection Handling:**
+- Store accepted connections in `[UUID: NWConnection]` dictionary
+- Implement same 4-second "traffic gate" stabilization logic
+- Track unstable connections (disconnect within 5 seconds)
+
+### 1.3 Guest Side: NWBrowser + NWConnection
+Replace `MCNearbyServiceBrowser` with `NWBrowser`:
+
+```swift
+let parameters = NWParameters.tcp
+parameters.includePeerToPeer = true
+
+let browser = NWBrowser(for: .bonjour(type: "_beamscribe._tcp", domain: nil), using: parameters)
+```
+
+**Discovery Results:**
+- `NWBrowser.Result` includes the service name (your event name)
+- Create `NWConnection` to connect to discovered host
+- Implement retry logic (1s, 2s, 4s backoff - same as current)
+
+---
+
+## Phase 2: BLE Fast-Discovery Layer (CoreBluetooth)
+
+### 2.1 Why BLE?
+Standard Bonjour discovery over AWDL can take 2-8 seconds due to channel hopping. BLE advertisement is near-instant and "wakes" the AWDL interface.
+
+### 2.2 BLEDiscoveryManager - Host (Peripheral)
+
+```swift
+let serviceUUID = CBUUID(string: "YOUR-BEAMSCRIBE-UUID")
+
+// Advertise when hosting
+peripheralManager.startAdvertising([
+ CBAdvertisementDataServiceUUIDsKey: [serviceUUID],
+ CBAdvertisementDataLocalNameKey: eventName.prefix(8) // BLE has 28-byte limit
+])
+```
+
+### 2.3 BLEDiscoveryManager - Guest (Central)
+
+```swift
+// Scan for BeamScribe hosts
+centralManager.scanForPeripherals(withServices: [serviceUUID])
+
+// On discovery, trigger Network.framework browser
+func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, ...) {
+ delegate?.bleDidDiscoverHost(name: peripheral.name)
+ // Start NWBrowser immediately - AWDL is now primed
+}
+```
+
+### 2.4 BLE Lifecycle
+- Start BLE advertising when `startHosting()` called
+- Start BLE scanning when `startBrowsing()` called
+- Stop BLE once TCP connection established (saves battery)
+- Resume BLE if connection lost (for reconnection)
+
+---
+
+## Phase 3: Data Transmission Layer
+
+### 3.1 Framing Protocol
+Network.framework uses raw TCP streams. Implement length-prefixed framing:
+
+```swift
+// Sending
+func send(_ packet: TranscriptPacket, to connection: NWConnection) {
+ let data = try JSONEncoder().encode(packet)
+ var length = UInt32(data.count).bigEndian
+ let header = Data(bytes: &length, count: 4)
+ connection.send(content: header + data, completion: .contentProcessed { ... })
+}
+
+// Receiving
+func receiveNextPacket(on connection: NWConnection) {
+ // Read 4-byte header first, then read that many bytes for payload
+}
+```
+
+### 3.2 Broadcast to All Guests (Host)
+Replace `MCSession.send(_:toPeers:with:)`:
+
+```swift
+func broadcastPacket(_ packet: TranscriptPacket) {
+ let data = encode(packet)
+ for (_, connection) in stableConnections {
+ connection.send(content: data, completion: ...)
+ }
+}
+```
+
+### 3.3 Packet Types (Keep Existing)
+Your current `TranscriptPacket` model works unchanged:
+- `.fullHistory` - Late-joiner sync
+- `.liveChunk` - Real-time transcription (partial/final)
+- `.alert` - Host disconnected, battery low, session resumed
+
+---
+
+## Phase 4: Integration with Existing Code
+
+### 4.1 File Changes Summary
+
+| Current File | Changes |
+|--------------|---------|
+| `MultipeerManager.swift` | Deprecate, keep as fallback initially |
+| `P2PConnectionManager.swift` | NEW - Main networking logic |
+| `BLEDiscoveryManager.swift` | NEW - CoreBluetooth layer |
+| `NetworkFraming.swift` | NEW - TCP framing utilities |
+| `SessionState.swift` | Update peer tracking types |
+| `ContentView.swift` | Swap manager reference |
+| `GuestBrowserView.swift` | Use new discovery delegate |
+| `Info.plist` | Add BLE background modes |
+
+### 4.2 Info.plist Additions
+```xml
+UIBackgroundModes
+
+ bluetooth-central
+ bluetooth-peripheral
+
+NSBluetoothAlwaysUsageDescription
+BeamScribe uses Bluetooth to quickly discover nearby sessions.
+```
+
+### 4.3 Fallback Strategy
+Keep `MultipeerManager` available initially:
+```swift
+// In SessionState or AppConfig
+var useNetworkFramework: Bool = true
+
+var connectionManager: any P2PConnectionDelegate {
+ useNetworkFramework ? p2pManager : legacyMultipeerManager
+}
+```
+
+---
+
+## Phase 5: Testing & Verification
+
+### 5.1 Terminal Checks
+```bash
+# Verify AWDL activates during connection
+ifconfig awdl0
+
+# Check for active peer-to-peer interface
+netstat -rn | grep awdl
+```
+
+### 5.2 Latency Testing
+```swift
+// Add to packet for round-trip measurement
+struct TranscriptPacket {
+ // existing fields...
+ var sentTimestamp: TimeInterval?
+}
+```
+
+### 5.3 Test Scenarios
+- [ ] Host starts, 1 guest joins
+- [ ] Host starts, 7 guests join simultaneously
+- [ ] Guest joins late, receives full history
+- [ ] Host disconnects, guests receive alert
+- [ ] App backgrounded, BLE keeps discovery alive
+- [ ] Same Wi-Fi network (should prefer infrastructure)
+- [ ] Different Wi-Fi / no Wi-Fi (should use AWDL)
+
+---
+
+## Implementation Order
+
+1. **P2PConnectionManager** - NWListener + NWBrowser (no BLE yet)
+2. **NetworkFraming** - Length-prefixed TCP framing
+3. **Integration** - Wire up to ContentView/GuestBrowserView
+4. **Testing** - Verify feature parity with MPC
+5. **BLEDiscoveryManager** - Add fast-discovery layer
+6. **Optimization** - Infrastructure vs AWDL preference logic
+7. **Cleanup** - Remove MultipeerManager fallback
+
+---
+
+## Estimated New Files
+
+```
+BeamScribe/
+├── Managers/
+│ ├── MultipeerManager.swift (existing - deprecate later)
+│ ├── P2PConnectionManager.swift (NEW - ~400 lines)
+│ ├── BLEDiscoveryManager.swift (NEW - ~150 lines)
+│ └── NetworkFraming.swift (NEW - ~80 lines)
+├── Models/
+│ ├── DiscoveredHost.swift (NEW - ~20 lines)
+│ └── ConnectedPeer.swift (NEW - ~20 lines)
+```
+
+---
+
+## Rollback Plan
+If issues arise:
+```bash
+git reset --hard backup-before-networkframework
+```
diff --git a/transition.md b/transition.md
new file mode 100644
index 0000000..75db8d1
--- /dev/null
+++ b/transition.md
@@ -0,0 +1,1257 @@
+# 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) -> 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 = []
+
+ // 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
+UIBackgroundModes
+
+ bluetooth-central
+ bluetooth-peripheral
+
+NSBluetoothAlwaysUsageDescription
+Uses Bluetooth to quickly discover nearby sessions.
+NSBluetoothPeripheralUsageDescription
+Uses Bluetooth to advertise sessions to nearby devices.
+NSLocalNetworkUsageDescription
+Uses local network for peer-to-peer connections.
+```
+
+Keep existing Bonjour services:
+```xml
+NSBonjourServices
+
+ _yourservice._tcp
+
+```
+
+---
+
+## 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 = []
+ private var pendingConnections: Set = [] // 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) {
+ 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) {
+ 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) {
+ 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.