diff --git a/PeerConnectivity.xcodeproj/project.pbxproj b/PeerConnectivity.xcodeproj/project.pbxproj index e3cdffe..b2520a3 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 */; }; 30SECURITY26060300000001 /* PeerSecurityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30SECURITY26060300000002 /* PeerSecurityConfiguration.swift */; }; 30SECURITY26060300000003 /* PeerSecurityConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30SECURITY26060300000004 /* PeerSecurityConfigurationTests.swift */; }; B20000022F30600000000001 /* ObservableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000022F30600000000002 /* ObservableTests.swift */; }; @@ -90,6 +91,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 = ""; }; B20000022F30600000000002 /* ObservableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableTests.swift; sourceTree = ""; }; B20000022F30600000000004 /* PeerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerTests.swift; sourceTree = ""; }; 3080C7DB1D80A1D600AF9EA3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sources/Info.plist; sourceTree = ""; }; @@ -206,6 +208,7 @@ 30ASYNCTEST26072101 /* AsyncObservableTests.swift */, 30NETADAPTTEST2607301 /* NetworkPeerTransportAdapterTests.swift */, 30BACKENDTEST2607301 /* PeerConnectionBackendTests.swift */, + 30LOOPBACKTEST2608061 /* NetworkPeerLoopbackTests.swift */, 30PEERMSG2602020000000001 /* PeerMessageTests.swift */, 30SECURITY26060300000004 /* PeerSecurityConfigurationTests.swift */, B20000022F30600000000002 /* ObservableTests.swift */, @@ -375,6 +378,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 */, 30SECURITY26060300000003 /* PeerSecurityConfigurationTests.swift in Sources */, B20000022F30600000000001 /* ObservableTests.swift in Sources */, diff --git a/PeerConnectivityDemo/ViewController.swift b/PeerConnectivityDemo/ViewController.swift index fddb5e0..606e2cc 100644 --- a/PeerConnectivityDemo/ViewController.swift +++ b/PeerConnectivityDemo/ViewController.swift @@ -33,7 +33,10 @@ class ViewController: UIViewController { } fileprivate lazy var pcm : PeerConnectionManager = { - let 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 + let pcm = PeerConnectionManager(serviceType: "local", displayName: displayName, backend: backend) pcm.listenOn({ [weak self] event in self?.handlePeerConnectionEvent(event) }, withKey: "demo.events") @@ -94,6 +97,9 @@ class ViewController: UIViewController { configureActions() refreshUI() appendLog(kind: "app.ready", detail: "Local peer: \(pcm.peer.displayName)") + if ProcessInfo.processInfo.arguments.contains("PCAutoStart") { + startNetworking() + } } override func viewDidDisappear(_ animated: Bool) { @@ -112,6 +118,12 @@ extension ViewController : UITextFieldDelegate { } private extension ViewController { + 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] + } + func configureLayout() { scrollView.translatesAutoresizingMaskIntoConstraints = false contentStack.translatesAutoresizingMaskIntoConstraints = false @@ -507,14 +519,17 @@ private extension ViewController { case .ready: appendLog(kind: "session.ready", detail: "Manager ready") case .started: + print("PeerConnectivityDemo started networking") appendLog(kind: "session.started", detail: mode.rawValue) case .ended: appendLog(kind: "session.ended", detail: "Manager stopped") case .devicesChanged(let peer, let peers): + print("PeerConnectivityDemo devices changed: \(peer.displayName) \(peer.status) connected: \(peers.map { $0.displayName })") connectedPeers = peers if !peers.isEmpty { checkedItems.insert(.peerConnected) } appendLog(kind: "peers.connected.changed", detail: "Changed: \(peer.displayName) [\(statusText(peer.status))]", peers: peers) case .foundPeer(let peer): + print("PeerConnectivityDemo found peer: \(peer.displayName)") checkedItems.insert(.peerDiscovered) appendLog(kind: "peer.found", detail: peer.displayName, peers: [peer]) case .foundPeerWithDiscoveryInfo(let peer, let discoveryInfo): 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 c14c6f4..9a7ff9e 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 1f7a5b4..40e10b8 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, discoveryInfo: nil) + return .foundPeer(peer, discoveryInfo: nil) } + 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 52bb76c..f609ca5 100644 --- a/Sources/Peer.swift +++ b/Sources/Peer.swift @@ -92,6 +92,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 fce8e73..52d3926 100644 --- a/Sources/PeerConnectionManager.swift +++ b/Sources/PeerConnectionManager.swift @@ -65,6 +65,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 { /** @@ -73,7 +75,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 } @@ -258,6 +261,7 @@ public class PeerConnectionManager { - parameter discoveryInfo: Public, unauthenticated metadata advertised to nearby browsers. - parameter invitationPolicy: Policy used to decide whether incoming invitations are accepted in `.automatic` mode. - parameter backend: Backend implementation to use. Defaults to `.multipeerConnectivity`. + The `.networkFramework` backend is experimental, unencrypted, and unauthenticated. - Returns: A fully initialized `PeerConnectionManager`. */ @@ -291,6 +295,7 @@ public class PeerConnectionManager { securityConfiguration: PeerSecurityConfiguration = .default, discoveryInfo: PeerDiscoveryInfo? = nil, invitationPolicy: PeerInvitationPolicy = .acceptAll, + backend: PeerConnectionBackend = .multipeerConnectivity, transportFactory: PeerConnectionTransportFactory) { self.init(serviceType: serviceType, connectionType: connectionType, @@ -298,7 +303,7 @@ public class PeerConnectionManager { securityConfiguration: securityConfiguration, discoveryInfo: discoveryInfo, invitationPolicy: invitationPolicy, - backend: .multipeerConnectivity, + backend: backend, transportFactory: transportFactory, shouldRegisterSharedManager: false) } @@ -315,7 +320,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) + } self.securityConfiguration = securityConfiguration self.discoveryInfo = discoveryInfo self.invitationPolicy = invitationPolicy