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.