Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions PeerConnectivity.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -90,6 +91,7 @@
30ASYNCTEST26072101 /* AsyncObservableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncObservableTests.swift; sourceTree = "<group>"; };
30NETADAPTTEST2607301 /* NetworkPeerTransportAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPeerTransportAdapterTests.swift; sourceTree = "<group>"; };
30BACKENDTEST2607301 /* PeerConnectionBackendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerConnectionBackendTests.swift; sourceTree = "<group>"; };
30LOOPBACKTEST2608061 /* NetworkPeerLoopbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPeerLoopbackTests.swift; sourceTree = "<group>"; };
B20000022F30600000000002 /* ObservableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableTests.swift; sourceTree = "<group>"; };
B20000022F30600000000004 /* PeerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerTests.swift; sourceTree = "<group>"; };
3080C7DB1D80A1D600AF9EA3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sources/Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
17 changes: 16 additions & 1 deletion PeerConnectivityDemo/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
rchatham marked this conversation as resolved.
pcm.listenOn({ [weak self] event in
self?.handlePeerConnectionEvent(event)
}, withKey: "demo.events")
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down
77 changes: 77 additions & 0 deletions PeerConnectivityTests/NetworkPeerLoopbackTests.swift
Original file line number Diff line number Diff line change
@@ -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")

Comment thread
rchatham marked this conversation as resolved.
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()
Comment thread
rchatham marked this conversation as resolved.
}
}
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 30 additions & 22 deletions Sources/NetworkPeerCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,16 @@ internal final class NetworkPeerCoordinator<Connection: NetworkPeerFrameSending>
}

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] = []) {
Expand All @@ -69,14 +71,16 @@ internal final class NetworkPeerCoordinator<Connection: NetworkPeerFrameSending>
}

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() {
Expand All @@ -89,45 +93,49 @@ internal final class NetworkPeerCoordinator<Connection: NetworkPeerFrameSending>
}

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)
let pending = pendingConnections.removeValue(forKey: identifier)
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) {
Expand All @@ -139,9 +147,9 @@ internal final class NetworkPeerCoordinator<Connection: NetworkPeerFrameSending>
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) {
Expand Down
9 changes: 5 additions & 4 deletions Sources/NetworkPeerTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Sources/Peer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading