From 83edcbb3afe3b33516650f22900b1671298434f1 Mon Sep 17 00:00:00 2001 From: Reid Chatham Date: Thu, 6 Aug 2026 14:08:40 -0700 Subject: [PATCH] feat: verify Network backend loopback --- PeerConnectivity.xcodeproj/project.pbxproj | 4 + PeerConnectivityDemo/ViewController.swift | 22 +++++- .../NetworkPeerLoopbackTests.swift | 77 +++++++++++++++++++ README.md | 6 ++ Sources/NetworkPeerCoordinator.swift | 52 +++++++------ Sources/NetworkPeerTransport.swift | 9 ++- Sources/Peer.swift | 6 ++ Sources/PeerConnectionManager.swift | 16 +++- 8 files changed, 161 insertions(+), 31 deletions(-) create mode 100644 PeerConnectivityTests/NetworkPeerLoopbackTests.swift diff --git a/PeerConnectivity.xcodeproj/project.pbxproj b/PeerConnectivity.xcodeproj/project.pbxproj index 236dcc8..1817ed5 100644 --- a/PeerConnectivity.xcodeproj/project.pbxproj +++ b/PeerConnectivity.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 30ASYNCTEST26072102 /* AsyncObservableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30ASYNCTEST26072101 /* AsyncObservableTests.swift */; }; 30NETADAPTTEST2607302 /* NetworkPeerTransportAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30NETADAPTTEST2607301 /* NetworkPeerTransportAdapterTests.swift */; }; 30BACKENDTEST2607302 /* PeerConnectionBackendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30BACKENDTEST2607301 /* PeerConnectionBackendTests.swift */; }; + 30LOOPBACKTEST2608062 /* NetworkPeerLoopbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30LOOPBACKTEST2608061 /* NetworkPeerLoopbackTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -86,6 +87,7 @@ 30ASYNCTEST26072101 /* AsyncObservableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncObservableTests.swift; sourceTree = ""; }; 30NETADAPTTEST2607301 /* NetworkPeerTransportAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPeerTransportAdapterTests.swift; sourceTree = ""; }; 30BACKENDTEST2607301 /* PeerConnectionBackendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerConnectionBackendTests.swift; sourceTree = ""; }; + 30LOOPBACKTEST2608061 /* NetworkPeerLoopbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPeerLoopbackTests.swift; sourceTree = ""; }; 3080C7DB1D80A1D600AF9EA3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sources/Info.plist; sourceTree = ""; }; 3080C7DC1D80A1D600AF9EA3 /* MultiObservable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MultiObservable.swift; path = Sources/MultiObservable.swift; sourceTree = ""; }; 3080C7DD1D80A1D600AF9EA3 /* Observable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Observable.swift; path = Sources/Observable.swift; sourceTree = ""; }; @@ -197,6 +199,7 @@ 30ASYNCTEST26072101 /* AsyncObservableTests.swift */, 30NETADAPTTEST2607301 /* NetworkPeerTransportAdapterTests.swift */, 30BACKENDTEST2607301 /* PeerConnectionBackendTests.swift */, + 30LOOPBACKTEST2608061 /* NetworkPeerLoopbackTests.swift */, 30PEERMSG2602020000000001 /* PeerMessageTests.swift */, 3086CD331D09FB9900E269A3 /* Info.plist */, ); @@ -362,6 +365,7 @@ 30ASYNCTEST26072102 /* AsyncObservableTests.swift in Sources */, 30NETADAPTTEST2607302 /* NetworkPeerTransportAdapterTests.swift in Sources */, 30BACKENDTEST2607302 /* PeerConnectionBackendTests.swift in Sources */, + 30LOOPBACKTEST2608062 /* NetworkPeerLoopbackTests.swift in Sources */, 30PEERMSG2602020000000002 /* PeerMessageTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/PeerConnectivityDemo/ViewController.swift b/PeerConnectivityDemo/ViewController.swift index d1cabb4..cb6499b 100644 --- a/PeerConnectivityDemo/ViewController.swift +++ b/PeerConnectivityDemo/ViewController.swift @@ -12,13 +12,19 @@ import PeerConnectivity class ViewController: UIViewController { fileprivate lazy var pcm : PeerConnectionManager = { - var pcm = PeerConnectionManager(serviceType: "local") + let arguments = ProcessInfo.processInfo.arguments + let displayName = ViewController.argumentValue(for: "PCDisplayName") ?? ProcessInfo.processInfo.hostName + let backend : PeerConnectionBackend = arguments.contains("PCNetworkBackend") ? .networkFramework : .multipeerConnectivity + var pcm = PeerConnectionManager(serviceType: "local", displayName: displayName, backend: backend) pcm.listenOn({ [weak self] (event) in switch event { + case .foundPeer(let peer): + print("PeerConnectivityDemo found peer: \(peer.displayName)") + case .devicesChanged(let peer, let connectedPeers): - _ = connectedPeers.map { print($0.displayName) } + print("PeerConnectivityDemo devices changed: \(peer.displayName) \(peer.status) connected: \(connectedPeers.map { $0.displayName })") defer { if let origin = self?.userStatusLabel?.frame.origin, @@ -36,6 +42,8 @@ class ViewController: UIViewController { self?.userStatusLabel?.text = connectedPeers.map { $0.displayName }.reduce("Connected to:") { $0 + "\n" + $1 } } + case .started: + print("PeerConnectivityDemo started networking") default: break } @@ -68,6 +76,10 @@ class ViewController: UIViewController { let frame = userStatusLabel.frame userStatusLabel.frame = frame.offsetBy(dx: 0, dy: frame.size.height*2) view.addSubview(userStatusLabel) + + if ProcessInfo.processInfo.arguments.contains("PCAutoStart") { + tappedConnectionButton(sender: connectionButton) + } } override func didReceiveMemoryWarning() { @@ -75,6 +87,12 @@ class ViewController: UIViewController { // Dispose of any resources that can be recreated. } + private static func argumentValue(for key: String) -> String? { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: key), arguments.indices.contains(index + 1) else { return nil } + return arguments[index + 1] + } + @objc internal func tappedConnectionButton(sender: UIButton) { switch isConnecting { case false: diff --git a/PeerConnectivityTests/NetworkPeerLoopbackTests.swift b/PeerConnectivityTests/NetworkPeerLoopbackTests.swift new file mode 100644 index 0000000..0f98805 --- /dev/null +++ b/PeerConnectivityTests/NetworkPeerLoopbackTests.swift @@ -0,0 +1,77 @@ +// +// NetworkPeerLoopbackTests.swift +// PeerConnectivityTests +// +// Created by Reid Chatham on 8/6/26. +// Copyright © 2026 Reid Chatham. All rights reserved. +// + +import XCTest +@testable import PeerConnectivity + +private struct LoopbackMessage : PeerMessage, Equatable { + internal static let messageType = "loopback-message" + internal let text : String +} + +final class NetworkPeerLoopbackTests : XCTestCase { + + internal func testNetworkBackendDiscoversConnectsAndExchangesMessage() { + guard #available(iOS 13.0, macOS 10.15, *) else { return } + + let serviceType = "pctest-\(UUID().uuidString.prefix(8).lowercased())" + let alice = PeerConnectionManager(serviceType: serviceType, + connectionType: .automatic, + displayName: "Alice", + backend: .networkFramework, + transportFactory: .networkFramework) + let bob = PeerConnectionManager(serviceType: serviceType, + connectionType: .automatic, + displayName: "Bob", + backend: .networkFramework, + transportFactory: .networkFramework) + let aliceFoundBob = expectation(description: "Alice found Bob") + let bobFoundAlice = expectation(description: "Bob found Alice") + let aliceConnected = expectation(description: "Alice connected") + let bobConnected = expectation(description: "Bob connected") + let bobReceivedMessage = expectation(description: "Bob received Alice message") + + alice.listenOn({ event in + switch event { + case .foundPeer(let peer) where peer.displayName == "Bob": + aliceFoundBob.fulfill() + case .devicesChanged(peer: let peer, connectedPeers: let connectedPeers) + where peer.displayName == "Bob" && connectedPeers.contains(where: { $0.displayName == "Bob" }): + aliceConnected.fulfill() + default: break + } + }, performListenerInBackground: true, withKey: "alice-events") + + bob.listenOn({ event in + switch event { + case .foundPeer(let peer) where peer.displayName == "Alice": + bobFoundAlice.fulfill() + case .devicesChanged(peer: let peer, connectedPeers: let connectedPeers) + where peer.displayName == "Alice" && connectedPeers.contains(where: { $0.displayName == "Alice" }): + bobConnected.fulfill() + default: break + } + }, performListenerInBackground: true, withKey: "bob-events") + + bob.observeMessages(ofType: LoopbackMessage.self, forKey: "bob-message") { message, peer in + XCTAssertEqual(peer.displayName, "Alice") + XCTAssertEqual(message, LoopbackMessage(text: "hello")) + bobReceivedMessage.fulfill() + } + + bob.start() + alice.start() + + wait(for: [aliceFoundBob, bobFoundAlice, aliceConnected, bobConnected], timeout: 15) + alice.sendMessage(LoopbackMessage(text: "hello"), toPeers: alice.connectedPeers) + wait(for: [bobReceivedMessage], timeout: 5) + + alice.stop() + bob.stop() + } +} diff --git a/README.md b/README.md index fab1fb7..b467879 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ CocoaPods and Carthage are no longer the recommended distribution paths for new The staged migration toward Apple's Network framework is tracked in [NetworkFrameworkMigrationPlan.md](NetworkFrameworkMigrationPlan.md), with follow-up PR sequencing in [NetworkMigrationPRPlan.md](NetworkMigrationPRPlan.md). +## Experimental Network framework backend + +`PeerConnectionManager` can be explicitly initialized with `backend: .networkFramework` on supported OS versions. This backend is still a migration/testing path: it is currently unencrypted and unauthenticated, lacks stream/resource/UI parity, and must not be used for sensitive data until a future hardening pass adds a production trust model. + +The default backend remains `.multipeerConnectivity`. + ## Creating/Stopping/Starting diff --git a/Sources/NetworkPeerCoordinator.swift b/Sources/NetworkPeerCoordinator.swift index 873868d..64e413e 100644 --- a/Sources/NetworkPeerCoordinator.swift +++ b/Sources/NetworkPeerCoordinator.swift @@ -52,14 +52,16 @@ internal final class NetworkPeerCoordinator } internal func receiveFrame(_ frame: PeerNetworkFrame, from connection: Connection) { - queue.sync { + let event : PeerSessionEvent? = queue.sync { switch frame.kind { case .handshake: - receiveHandshake(frame.payload, from: connection) + return receiveHandshake(frame.payload, from: connection) case .data: - receiveData(frame.payload, from: connection) + return receiveData(frame.payload, from: connection) } } + guard let event = event else { return } + sessionObserver.value = event } internal func sendData(_ data: Data, toPeers peers: [Peer] = []) { @@ -69,14 +71,16 @@ internal final class NetworkPeerCoordinator } internal func removeConnection(_ connection: Connection) { - queue.sync { + let event : PeerSessionEvent? = queue.sync { let identifier = ObjectIdentifier(connection) pendingConnections.removeValue(forKey: identifier) - guard let identity = connectionIdentities.removeValue(forKey: identifier) else { return } - guard registry.connection(for: identity) === connection else { return } + guard let identity = connectionIdentities.removeValue(forKey: identifier) else { return nil } + guard registry.connection(for: identity) === connection else { return nil } registry.remove(identity: identity) - sessionObserver.value = .devicesChanged(peer: Peer(identity: identity, status: .notConnected)) + return .devicesChanged(peer: Peer(identity: identity, status: .notConnected)) } + guard let event = event else { return } + sessionObserver.value = event } internal func cancelAllConnections() { @@ -89,32 +93,36 @@ internal final class NetworkPeerCoordinator } internal func foundPeer(identity: PeerIdentity) { - queue.sync { - guard identity != localPeer.identity else { return } + let event : PeerBrowserEvent? = queue.sync { + guard identity != localPeer.identity else { return nil } let peer = Peer(identity: identity, status: .notConnected) discoveredPeers[identity] = peer - browserObserver.value = .foundPeer(peer) + return .foundPeer(peer) } + guard let event = event else { return } + browserObserver.value = event } internal func lostPeer(identity: PeerIdentity) { - queue.sync { - guard identity != localPeer.identity else { return } + let event : PeerBrowserEvent? = queue.sync { + guard identity != localPeer.identity else { return nil } let peer = discoveredPeers.removeValue(forKey: identity) ?? Peer(identity: identity, status: .notConnected) - browserObserver.value = .lostPeer(peer) + return .lostPeer(peer) } + guard let event = event else { return } + browserObserver.value = event } - fileprivate func receiveHandshake(_ data: Data, from connection: Connection) { + fileprivate func receiveHandshake(_ data: Data, from connection: Connection) -> PeerSessionEvent? { guard let handshake = try? JSONDecoder().decode(PeerNetworkHandshake.self, from: data), handshake.protocolVersion == PeerNetworkHandshake.currentProtocolVersion else { rejectHandshake(from: connection) - return + return nil } guard handshake.identity != localPeer.identity else { rejectHandshake(from: connection) - return + return nil } let identifier = ObjectIdentifier(connection) @@ -122,12 +130,12 @@ internal final class NetworkPeerCoordinator let direction = pending?.direction ?? NetworkPeerConnectionDirection.inbound let wasConnected = registry.connection(for: handshake.identity) != nil let isRegistered = registry.register(connection, for: handshake.identity, direction: direction) - guard isRegistered else { return } + guard isRegistered else { return nil } removeConnectionIdentity(for: handshake.identity) connectionIdentities[identifier] = handshake.identity - guard !wasConnected else { return } - sessionObserver.value = .devicesChanged(peer: Peer(identity: handshake.identity, status: .connected)) + guard !wasConnected else { return nil } + return .devicesChanged(peer: Peer(identity: handshake.identity, status: .connected)) } fileprivate func rejectHandshake(from connection: Connection) { @@ -139,9 +147,9 @@ internal final class NetworkPeerCoordinator connectionIdentities = connectionIdentities.filter { $0.value != identity } } - fileprivate func receiveData(_ data: Data, from connection: Connection) { - guard let identity = connectionIdentities[ObjectIdentifier(connection)] else { return } - sessionObserver.value = .didReceiveData(peer: Peer(identity: identity, status: .connected), data: data) + fileprivate func receiveData(_ data: Data, from connection: Connection) -> PeerSessionEvent? { + guard let identity = connectionIdentities[ObjectIdentifier(connection)] else { return nil } + return .didReceiveData(peer: Peer(identity: identity, status: .connected), data: data) } fileprivate func sendHandshake(on connection: Connection) { diff --git a/Sources/NetworkPeerTransport.swift b/Sources/NetworkPeerTransport.swift index 99e9866..742d4b8 100644 --- a/Sources/NetworkPeerTransport.swift +++ b/Sources/NetworkPeerTransport.swift @@ -102,9 +102,10 @@ internal final class NetworkPeerConnection : NetworkPeerConnectionCancellable { } internal static func parameters() -> NWParameters { - // Network transport remains internal scaffolding; before exposing it publicly, - // provide app-configurable TLS identity or PSK verification for authenticated sessions. - let parameters = NWParameters(tls: NWProtocolTLS.Options(), tcp: NWProtocolTCP.Options()) + // Network transport remains experimental opt-in scaffolding; a later hardening + // pass must provide app-configurable TLS identity or PSK verification before + // recommending this backend for production sessions. + let parameters = NWParameters.tcp parameters.includePeerToPeer = true return parameters } @@ -179,7 +180,7 @@ internal final class NetworkPeerBrowser : NetworkPeerBrowsing { resultHandler: ResultHandler? = nil, stateHandler: StateHandler? = nil) { let service = PeerNetworkBonjourService(serviceType: serviceType) - let descriptor = NWBrowser.Descriptor.bonjour(type: service.bonjourType, domain: nil) + let descriptor = NWBrowser.Descriptor.bonjourWithTXTRecord(type: service.bonjourType, domain: nil) browser = NWBrowser(for: descriptor, using: NetworkPeerConnection.parameters()) self.queue = queue self.resultHandler = resultHandler diff --git a/Sources/Peer.swift b/Sources/Peer.swift index b27ba04..6beb650 100644 --- a/Sources/Peer.swift +++ b/Sources/Peer.swift @@ -73,6 +73,12 @@ public struct Peer { identity = PeerIdentity(peerID: peerID) status = .currentUser } + + internal init(networkDisplayName displayName: String) { + peerID = MCPeerID(displayName: displayName) + identity = PeerIdentity(identifier: UUID().uuidString, displayName: displayName) + status = .currentUser + } } extension PeerIdentity { diff --git a/Sources/PeerConnectionManager.swift b/Sources/PeerConnectionManager.swift index d2c2f91..f04e4ec 100644 --- a/Sources/PeerConnectionManager.swift +++ b/Sources/PeerConnectionManager.swift @@ -56,6 +56,8 @@ extension PeerMessage { The default backend is `.multipeerConnectivity`, preserving existing runtime behavior. The `.networkFramework` backend is an opt-in migration path and does not yet provide full parity for MultipeerConnectivity browser UI, streams, or resource transfer. + It is currently unencrypted and unauthenticated, and must not be used for sensitive + data until a future hardening pass adds a production trust model. */ public enum PeerConnectionBackend : Equatable { /** @@ -64,7 +66,8 @@ public enum PeerConnectionBackend : Equatable { case multipeerConnectivity /** Use Apple's Network framework. This backend is experimental and currently supports - discovery, connection scaffolding, and reliable data transport only. + discovery, connection scaffolding, and reliable data transport only. It is currently + unencrypted and unauthenticated; do not use it for sensitive data. */ case networkFramework } @@ -195,6 +198,7 @@ public class PeerConnectionManager { - parameter connectionType: Takes a PeerConnectionType case determining the default behavior of the framework. - parameter displayName: The local user's display name to other peers. - parameter backend: Backend implementation to use. Defaults to `.multipeerConnectivity`. + The `.networkFramework` backend is experimental, unencrypted, and unauthenticated. - Returns: A fully initialized `PeerConnectionManager`. */ @@ -219,11 +223,12 @@ public class PeerConnectionManager { internal convenience init(serviceType: ServiceType, connectionType: PeerConnectionType = .automatic, displayName: String, + backend: PeerConnectionBackend = .multipeerConnectivity, transportFactory: PeerConnectionTransportFactory) { self.init(serviceType: serviceType, connectionType: connectionType, displayName: displayName, - backend: .multipeerConnectivity, + backend: backend, transportFactory: transportFactory, shouldRegisterSharedManager: false) } @@ -237,7 +242,12 @@ public class PeerConnectionManager { self.connectionType = connectionType self.backend = backend self.serviceType = serviceType - self.peer = Peer(displayName: displayName) + switch backend { + case .multipeerConnectivity: + self.peer = Peer(displayName: displayName) + case .networkFramework: + self.peer = Peer(networkDisplayName: displayName) + } session = transportFactory.makeSession(peer, sessionObserver) browser = transportFactory.makeBrowser(session, serviceType, browserObserver)