diff --git a/Artifacts/peer-browser-network-demo.png b/Artifacts/peer-browser-network-demo.png
new file mode 100644
index 0000000..3358ea2
Binary files /dev/null and b/Artifacts/peer-browser-network-demo.png differ
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52fa89f..f4a5319 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
- New: `PeerConnectivityUI` browser peer filtering with discovery metadata
- Compatibility: `foundPeer` remains emitted alongside `foundPeerWithDiscoveryInfo`, so listeners should handle one discovery event to avoid processing the same peer twice
- Compatibility: automatic non-manual invitation policies still emit `.receivedInvitation` for observation, but the event handler is a no-op and policy decisions remain authoritative
+ - Compatibility: listener registration now observes future connection events only instead of replaying the responder's most recently stored event; state-oriented internal observables still replay their current value
- Packaging: the core target now links both Network.framework and MultipeerConnectivity.framework
- Tooling: the Swift package manifest uses Swift tools 6.0, so SwiftPM consumers need a Swift 6 toolchain (Xcode 16 or newer); the library remains compiled in Swift 5 language mode
- Platforms: the minimum supported versions increase to iOS 13 and macOS 10.15; CocoaPods consumers need an Xcode version with those platform SDKs (Xcode 11 or newer)
diff --git a/NetworkBackendGuide.md b/NetworkBackendGuide.md
index 467c246..17698fb 100644
--- a/NetworkBackendGuide.md
+++ b/NetworkBackendGuide.md
@@ -6,15 +6,36 @@ PeerConnectivity is migrating toward Apple's Network framework while preserving
The Network backend is not the default runtime path yet. Treat it as an experimental/beta backend for apps that can validate behavior in their own topology and OS/device matrix.
-Use it when you need to evaluate the Network framework migration path for reliable local peer messaging. Continue using the default MultipeerConnectivity backend when you need browser UI, stream transfer, resource transfer, or proven production parity.
+Use it when you need to evaluate the Network framework migration path for reliable local peer messaging. Continue using the default MultipeerConnectivity backend when you need the system-provided browser UI, stream transfer, resource transfer, stream/resource receive events, or proven production parity.
-## Requirements
+## Production app setup
-- `.networkFramework` requires iOS 13.0+ or macOS 10.15+.
-- iOS apps that use Bonjour/local-network discovery should include local network privacy entries in `Info.plist`:
- - `NSLocalNetworkUsageDescription`
- - `NSBonjourServices`, including the DNS-SD form of your service type, for example `_local._tcp`.
-- Service types passed to `PeerConnectionManager` remain bare PeerConnectivity service names such as `"local"`; the Network backend maps them to Bonjour service names internally.
+`.networkFramework` requires iOS 13.0+ or macOS 10.15+. Before distributing an app that selects this backend, add the local-network privacy declarations to the **app target's built `Info.plist`**. Adding them to the package or framework plist does not configure an adopting app.
+
+For a manager created with `serviceType: "local"`, use:
+
+```xml
+NSLocalNetworkUsageDescription
+Discover and connect to nearby devices running this app.
+NSBonjourServices
+
+ _local._tcp
+
+```
+
+Write a purpose string that accurately describes the app's user-facing feature. Declare every service type the app passes to a Network-backed manager. The current conversion is:
+
+| `PeerConnectionManager` service type | Bonjour type to declare in `NSBonjourServices` |
+|---|---|
+| `"local"` | `_local._tcp` |
+| `"test-service"` | `_test-service._tcp` |
+| `"_test-service._tcp"` | `_test-service._tcp` |
+
+Prefer the bare form, such as `"local"`, because it is compatible with the existing MultipeerConnectivity API. The Network backend adds the leading underscore and `._tcp` suffix. It uses TCP only, so do not add a corresponding `._udp` entry unless the adopting app separately advertises or browses that UDP service. The demo's complete example is in [`PeerConnectivityDemo/Info.plist`](PeerConnectivityDemo/Info.plist).
+
+These values are app metadata, not entitlements. This backend's Bonjour-over-TCP implementation does not send custom multicast or broadcast packets, so it does not itself require the restricted multicast networking entitlement.
+
+On systems that enforce local-network privacy, starting Bonjour discovery can present the system prompt using `NSLocalNetworkUsageDescription`. The user can deny access, and the app must treat unavailable discovery as a real runtime state rather than assuming that an empty peer list means no peers exist.
## Opt in
@@ -24,7 +45,7 @@ Create the manager with `backend: .networkFramework`:
import Foundation
import PeerConnectivity
-let secret = Data("replace-with-an-app-managed-secret".utf8)
+let secret : Data = loadProvisionedNetworkPSK() // At least 32 random bytes; app-defined provisioning.
let manager = PeerConnectionManager(serviceType: "local",
connectionType: .automatic,
displayName: "Alice",
@@ -47,18 +68,22 @@ let manager = PeerConnectionManager(serviceType: "local",
networkSecurity: security)
```
-`PeerConnectionNetworkSecurity.preSharedKey(_:)` configures TLS with a pre-shared key and requires exactly TLS 1.2. Apple's external PSK API, `sec_protocol_options_add_pre_shared_key`, supports PSK negotiation only in TLS 1.2, not TLS 1.3, so the transport pins both its minimum and maximum protocol versions to TLS 1.2. The modern minimum- and maximum-version setters are available across the Network backend's deployment range (iOS 13.0+ and macOS 10.15+). Peers must use the same non-empty key to complete the TLS handshake; negotiation failure does not fall back to another TLS version or to plaintext.
+`PeerConnectionNetworkSecurity.preSharedKey(_:)` configures TLS with a pre-shared key and requires exactly TLS 1.2. Apple's external PSK API, `sec_protocol_options_add_pre_shared_key`, supports PSK negotiation only in TLS 1.2, not TLS 1.3, so the transport pins both its minimum and maximum protocol versions to TLS 1.2. The modern minimum- and maximum-version setters are available across the Network backend's deployment range (iOS 13.0+ and macOS 10.15+). Peers must use the same non-empty key to complete the TLS handshake; negotiation failure does not fall back to another TLS version or to plaintext. This authenticates each endpoint only as a member of the key-sharing group, not as a particular person, device, account, or installation.
Guidance for app-managed secrets:
-- Use high-entropy key material, not a human-readable demo string.
-- Store and rotate the secret according to your app's threat model.
-- Use the same secret only for peers that should be allowed into the same local mesh.
+- Generate at least 256 random bits (32 bytes) with a cryptographically secure random-number generator. Do not use a password, passphrase, display name, service name, UUID text, predictable token, or demo string.
+- Provision the key over an authenticated channel; keep it out of source, logs, Bonjour metadata, and the application bundle; store it with platform-appropriate protection.
+- Scope the key to one app/environment and authorization group. Do not reuse it across unrelated protocols or groups.
+- Rotate the key when membership changes or compromise is suspected.
+- Treat every holder of the shared key as equally authorized under this mode.
- Treat Bonjour TXT metadata (`pc-id`, `pc-name`, `pc-v`) as routing/discovery metadata only. It is not a trust assertion.
`.unauthenticated` is plaintext TCP. It remains available only for migration compatibility and diagnostics and must not be used for sensitive data.
-Current limitation: TLS-PSK authenticates membership in the shared-key group; it does not yet bind a long-term public peer identity to a certificate or pinned key. If multiple devices share the same PSK, any member of that group can advertise a display name. Apps that need stronger identity guarantees should keep the Network backend opt-in until a stricter trust model is added.
+Current limitation: TLS-PSK authenticates membership in the shared-key group; it does not bind the self-asserted handshake identifier or display name to an individual credential. Any member can claim another member's display name or identifier, so apps must not use `Peer.displayName` or the internal transport identifier as an authorization principal or trustworthy audit identity. Apps that need stronger identity guarantees should keep the Network backend opt-in until a stricter trust model is added.
+
+See [NetworkTrustModelPlan.md](NetworkTrustModelPlan.md) for the insider spoofing threat model, exact PSK requirements, and future options including HKDF-derived scoped/pairwise keys, signed per-peer identity binding, certificate/pinning mode, and an app-provided verifier.
## Connection modes
@@ -68,30 +93,30 @@ Supported. Peers advertise and browse for the same service type, then attempt to
### `.custom`
-Supported for app-owned peer selection. Observe `.foundPeer` and `.lostPeer`, then call `invitePeer` for the selected peer:
+Supported for app-owned peer selection. `PeerBrowserModel` is the supported Network replacement foundation for `MCBrowserViewController` during this migration phase. It tracks discovered peers and connection status without prescribing UIKit or SwiftUI presentation.
+
+For example, an app-owned table view controller can bind the model to its own state and invite only after selection:
```swift
-var discoveredPeers : [Peer] = []
-
-manager.listenOn({ event in
- switch event {
- case .foundPeer(let peer):
- // Add `peer` to app UI.
- discoveredPeers.append(peer)
- case .lostPeer(let peer):
- // Remove `peer` from app UI.
- discoveredPeers.removeAll { $0 == peer }
- default:
- break
- }
-}, withKey: "network-browser")
-
-// Later, after user/app approval:
-if let selectedPeer = discoveredPeers.first {
- manager.invitePeer(selectedPeer)
+private var discoveredPeers : [Peer] = []
+private lazy var browserModel = PeerBrowserModel(manager: manager) { [weak self] peers in
+ self?.discoveredPeers = peers
+ self?.tableView.reloadData() // Callback is delivered on the main queue.
+}
+
+override func viewDidLoad() {
+ super.viewDidLoad()
+ browserModel.startObserving()
+ manager.start()
+}
+
+override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ browserModel.invitePeer(discoveredPeers[indexPath.row])
}
```
+Own selection, empty/error states, accessibility, styling, and the model lifecycle in the app. Call `stopObserving()` when observation should end; the model also stops observing on deinitialization.
+
For Network-backed managers, `invitePeer(_:withContext:timeout:)` uses the discovered peer endpoint. The `context` and `timeout` parameters are currently ignored.
### `.inviteOnly`
@@ -100,6 +125,12 @@ The built-in MultipeerConnectivity advertiser assistant/browser UI is not availa
`PeerConnectivityUI.browserViewController` returns `nil` for Network-backed managers.
+## Browser UI decision
+
+A reusable SwiftUI or UIKit Network browser is intentionally deferred. Peer selection is product-specific, and the migration does not yet have enough app usage to establish stable shared behavior for selection, cancellation, connection progress, errors, accessibility, or presentation. Adding that surface now would increase UI and compatibility scope while the Network backend remains opt-in.
+
+`PeerBrowserModel` is therefore the supported app-owned UI foundation for this phase. The MultipeerConnectivity-only `MCBrowserViewController` compatibility path remains unchanged, and no backend default changes as part of this decision. A reusable component can be reconsidered after app-owned integrations validate common requirements.
+
## API support matrix
| API / behavior | MultipeerConnectivity backend | Network backend |
@@ -113,9 +144,10 @@ The built-in MultipeerConnectivity advertiser assistant/browser UI is not availa
| Large framed messages | ✅ via MC | ✅ via TCP framing |
| Multi-peer broadcast | ✅ | ✅ bounded local E2E coverage |
| Disconnect/reconnect after peer restart | ✅ | ✅ bounded local E2E coverage |
-| `sendDataStream` | ✅ | ❌ unsupported-operation error |
-| `sendResourceAtURL` | ✅ | ❌ unsupported-operation error |
-| Stream/resource receive events | ✅ | ❌ not implemented |
+| `sendDataStream` | ✅ | ❌ MultipeerConnectivity-only; throws unsupported-operation error |
+| `sendResourceAtURL` | ✅ | ❌ MultipeerConnectivity-only; returns `nil` progress and reports an unsupported-operation error |
+| `.receivedStream` | ✅ | ❌ MultipeerConnectivity-only; never emitted |
+| `.startedReceivingResource` / `.finishedReceivingResource` | ✅ | ❌ MultipeerConnectivity-only; never emitted |
| `multipeerSession` | ✅ | ❌ programmer error |
| TLS-PSK transport security | MC-managed | ✅ TLS 1.2 only with `.preSharedKey` |
@@ -136,24 +168,65 @@ let message = ChatMessage(text: "hello")
manager.sendMessage(message, toPeers: manager.connectedPeers)
```
-Resource transfer and stream APIs are intentionally unsupported for the Network backend in the current migration stack. Calls fail explicitly instead of silently degrading behavior.
+## Stream and resource compatibility decision
+
+`sendDataStream`, `sendResourceAtURL`, `.receivedStream`, `.startedReceivingResource`, and `.finishedReceivingResource` remain MultipeerConnectivity-only APIs for the current Network backend. They are not deprecated because they remain supported when using `.multipeerConnectivity`, but selecting `.networkFramework` does not provide alternate stream or resource semantics.
+
+This migration stack will not add a custom stream or file-transfer protocol. Network framework has no direct equivalents for the MultipeerConnectivity APIs, and emulating them would require new framing, flow-control, progress, cancellation, persistence, and protocol-versioning contracts beyond the reliable `Data` and `PeerMessage` transport being migrated.
+
+Network-backed `sendDataStream` calls throw an unsupported-operation error. Network-backed `sendResourceAtURL` calls return `nil` progress for each requested peer and invoke the completion handler with an unsupported-operation error. The Network backend never emits the stream or resource receive events. Calls fail explicitly rather than silently changing transport behavior.
+
+Apps that need to exchange bounded in-memory payloads should use `sendData` or `sendMessage`. Apps that require stream or resource transfer must keep those sessions on `.multipeerConnectivity`. A future, separately scoped feature may revisit file or streaming transport, but it is not a parity requirement for the current Network migration.
## Demo app
-The demo app can be launched with arguments to exercise the Network backend:
+The expanded demo shows the active backend and lets you choose **Multipeer** or **Network**, plus **Automatic** or **Require Invitation**, before starting. The default backend remains MultipeerConnectivity. Each backend remembers its connection-behavior choice for the demo session, with defaults that preserve prior behavior: Multipeer uses `.automatic`, while Network uses `.custom` for **Require Invitation**. Automatic mode uses each backend's automatic behavior. Require Invitation uses `PeerBrowserModel` for app-owned discovery and exposes manual **Invite _peer name_** actions for either backend. **Send Typed Message** exercises `PeerMessage` delivery while preserving message history, structured logging, troubleshooting, and the test checklist.
+
+The same path can be selected with launch arguments:
-- `PCNetworkBackend` — use `.networkFramework` instead of the default MultipeerConnectivity backend.
+- `PCNetworkBackend` — select `.networkFramework` instead of the default MultipeerConnectivity backend.
- `PCAutoStart` — start the manager on launch.
- `PCDisplayName ` — set a deterministic display name such as `Alice` or `Bob`.
-Example simulator launch arguments:
+Example arguments for two simulator or device instances:
```text
PCNetworkBackend PCAutoStart PCDisplayName Alice
PCNetworkBackend PCAutoStart PCDisplayName Bob
```
-The demo path is intended for local validation while the backend remains opt-in.
+For **Require Invitation**, tap the enabled invite action for a discovered peer, wait for its status to become **Connected**, then enter and send a typed message or ping. The backend and connection-behavior selectors remain disabled until networking is stopped. See [`PeerConnectivityDemo/README.md`](PeerConnectivityDemo/README.md) for the complete walkthrough.
+
+This demo Network path is intentionally unauthenticated, visibly labels that limitation, and is only for non-sensitive local migration validation. Production apps should use app-managed `.preSharedKey` material and an appropriate trust model.
+
+## Network path and device caveats
+
+PeerConnectivity sets `NWParameters.includePeerToPeer = true` on the parameters used by its Network listener, browser, and connections. Apple documents this as opting in to peer-to-peer link technologies, and more specifically describes the Network framework path as Apple peer-to-peer Wi-Fi. This is an opt-in, not a request for a particular interface or a guarantee that a peer-to-peer path will be selected.
+
+Plan around these boundaries:
+
+- Two devices on the same infrastructure Wi-Fi can communicate locally without internet access, provided the network permits client-to-client traffic and Bonjour. Guest-network isolation, managed-network policy, VPNs, and firewalls can prevent discovery or connection.
+- Keep Wi-Fi enabled when validating peer-to-peer operation. Do not describe this backend as Bluetooth-only or as a Bluetooth LE transport; it has no Core Bluetooth API or explicit Bluetooth transport selection.
+- AWDL is commonly used as shorthand for an Apple peer-to-peer Wi-Fi implementation detail. The public API used here exposes only `includePeerToPeer`; apps cannot require AWDL, select it, or infer from that flag which interface carried a connection.
+- Radio state, device/OS combinations, network policy, and nearby interference can affect results. Enabling `includePeerToPeer` does not promise discovery under every topology.
+- Stop managers, browsers, and connections when the feature is no longer in use. Apple notes that peer-to-peer Wi-Fi operation can affect network performance.
+
+The simulator is useful for API flow, UI, and loopback automation, and it may discover local Bonjour services through the Mac's networking environment. It is not a production validation substitute: simulator privacy behavior and interfaces differ from a physical device, and it cannot establish confidence in on-device peer-to-peer Wi-Fi, radio-state, or Local Network permission behavior.
+
+## Manual physical-device validation
+
+Complete this checklist on the release build (or an equivalently signed build) before shipping the Network backend:
+
+- [ ] Confirm the built app's `Info.plist` contains the intended `NSLocalNetworkUsageDescription` and every required `NSBonjourServices` value, such as `_local._tcp` for `serviceType: "local"`.
+- [ ] Install cleanly on two supported physical devices so permission state is known; start networking and verify the Local Network prompt presents with the intended copy.
+- [ ] Allow access on both devices, then verify discovery, invitation/automatic connection as applicable, bidirectional typed messages, disconnect, and reconnect.
+- [ ] Deny Local Network access on one device and verify the app shows an actionable unavailable/permission state rather than hanging, crashing, or claiming no peers exist. Restore access in Settings and retest.
+- [ ] Verify two devices on the supported infrastructure Wi-Fi topology, including the production router or managed network when relevant. Confirm the feature does not depend on internet reachability.
+- [ ] Separately validate the product's required nearby peer-to-peer scenario with Wi-Fi enabled and without relying on the infrastructure path. Record device models and OS versions; do not infer the selected interface from success alone.
+- [ ] Exercise app background/foreground transitions and stopping/restarting networking; confirm stale peers disappear and resources are released.
+- [ ] Repeat the security checks with production-equivalent `.preSharedKey` provisioning: matching keys connect, mismatched keys do not, and no key material appears in logs, Bonjour metadata, or the app bundle.
+
+If the product requires a specific topology (for example, a managed venue network or operation away from an access point), test that exact topology across the supported physical-device and OS matrix. A simulator-only pass is not a release gate.
## Connection policy defaults
@@ -191,7 +264,7 @@ swift test --filter NetworkPeerLoopbackTests
In CI, the full Swift/Xcode test steps skip `NetworkPeerLoopbackTests` by default and then run them in focused retryable steps with `PEERCONNECTIVITY_RUN_NETWORK_E2E=1`. This keeps real Bonjour/Network.framework failures isolated from unit-test failures while still requiring the Network E2E checks to pass.
-Full local verification for the migration stack:
+Full local automated verification for the migration stack (in addition to the physical-device checklist above):
```sh
swift test
@@ -201,10 +274,19 @@ xcodebuild test -project PeerConnectivity.xcodeproj \
-configuration Debug
```
+## Apple references
+
+- [`NSLocalNetworkUsageDescription`](https://developer.apple.com/documentation/bundleresources/information-property-list/nslocalnetworkusagedescription) — Apple requires a purpose string for apps that access the local network directly or through Bonjour.
+- [TN3151: Choosing the right networking API](https://developer.apple.com/documentation/technotes/tn3151-choosing-the-right-networking-api) — Bonjour, local-network privacy, and peer-to-peer Wi-Fi guidance.
+- [Local Network Privacy FAQ-14](https://developer.apple.com/forums/thread/663814) — Apple's mapping from a bare Multipeer Connectivity service type to `_service._tcp` in `NSBonjourServices`.
+- [`NWListener.service`](https://developer.apple.com/documentation/network/nwlistener/service-swift.property) — the Bonjour service advertised by a Network listener.
+
## Known follow-ups
+See [NetworkMigrationReadinessAudit.md](NetworkMigrationReadinessAudit.md) for the authoritative stable/default/removal gates and PR #50+ priority classification.
+
- Revisit public Network connection policy configuration after more device and CI validation.
-- Add a Network-native peer browser UI/model for apps that need built-in selection UI.
-- Decide whether to implement Network equivalents for streams and resource transfer or document them as MultipeerConnectivity-only long term.
-- Strengthen identity binding beyond shared-key group membership for apps that require per-peer authentication.
+- Reconsider a reusable Network-native browser component only after app-owned `PeerBrowserModel` integrations establish common UI requirements.
+- Revisit stream or file transfer only as a separately scoped future feature; these APIs remain MultipeerConnectivity-only for the current Network backend.
+- Implement an individually authenticated identity mode only after the design and gates in [NetworkTrustModelPlan.md](NetworkTrustModelPlan.md) receive focused security review.
- Continue monitoring Bonjour/Network.framework E2E behavior in CI and split or gate slow tests if they become flaky.
diff --git a/NetworkFrameworkMigrationPlan.md b/NetworkFrameworkMigrationPlan.md
index 8a06a1b..b14ca69 100644
--- a/NetworkFrameworkMigrationPlan.md
+++ b/NetworkFrameworkMigrationPlan.md
@@ -4,6 +4,8 @@
Begin migrating PeerConnectivity away from direct MultipeerConnectivity dependence toward Apple's Network framework while preserving public API compatibility where practical.
+> Status: this is the original staged architecture plan. The Network backend is now implemented as an experimental opt-in. Use [NetworkMigrationReadinessAudit.md](NetworkMigrationReadinessAudit.md) for the authoritative checklist governing stable status, a default switch, and MultipeerConnectivity removal.
+
> Note: the iOS 27 MultipeerConnectivity deprecation claim has not yet been verified against Apple SDK headers or release notes. Treat Network framework migration as proactive risk reduction until confirmed.
## Current State
@@ -56,9 +58,9 @@ Most public event types are already framework-neutral, which makes an incrementa
| `MCSessionState` / `Peer.Status` | `NWConnection.State` mapping | Must define how `.setup`, `.waiting`, `.preparing`, `.ready`, `.failed`, and `.cancelled` map to existing public statuses/events. |
| `MCPeerID` | New framework-neutral peer identity | Must preserve display name and stable equality semantics. |
| `MCSession.send` | `NWConnection.send` + message framing | Network does not preserve message boundaries automatically. |
-| Resource transfer | Custom protocol over `NWConnection` | Later slice; potentially file chunks/progress messages. |
-| Stream transfer | Custom stream abstraction or compatibility shim | Later slice; no one-to-one replacement. |
-| `MCBrowserViewController` | Custom PeerConnectivityUI browser | Required for UI package. |
+| Resource transfer | No Network replacement in the current migration | `sendResourceAtURL` and resource receive events remain MultipeerConnectivity-only. |
+| Stream transfer | No Network replacement in the current migration | `sendDataStream` and stream receive events remain MultipeerConnectivity-only. |
+| `MCBrowserViewController` | `PeerBrowserModel` + app-owned UI | Reusable PeerConnectivityUI replacement deferred pending app validation. |
Relevant Network framework APIs:
@@ -78,7 +80,7 @@ Relevant Network framework APIs:
- `NSBonjourServices`
- Network framework is connection-oriented, not an `MCSession`-style symmetric mesh abstraction.
- Peer-to-peer Wi-Fi/Bluetooth/AWDL behavior requires `NWParameters.includePeerToPeer = true` and on-device testing.
-- Network-backed transports must keep TLS enabled before any public backend selection is exposed; peer identities remain self-asserted until a later authentication/trust model binds them to TLS identity or app-provided verification.
+- Network external-PSK transport pins both its minimum and maximum to TLS 1.2 with no fallback to another TLS version or plaintext. It authenticates shared-key group membership, not an individual peer. Peer identities remain self-asserted until the [Network trust model plan](NetworkTrustModelPlan.md) binds them to pairwise key material, a certificate/pinned key, a signed credential, or app-provided verification.
- Network coordinator state must be serialized and bounded; current wiring keeps handshake timeout and connection caps internal until defaults are validated across CI and device testing. Discovery caps and idle timeouts remain production-hardening follow-ups before accepting untrusted inbound traffic at scale.
- Public `multipeerSession: MCSession` prevents completely removing MultipeerConnectivity without a breaking API change; a source-compatible transition release requires dual backend support.
- `MCBrowserViewController` has no Network framework equivalent.
@@ -160,23 +162,28 @@ Acceptance criteria:
- Data send/receive works for JSON `PeerMessage` payloads.
- Connection state changes map to existing PeerConnectivity events.
-### Phase 4 — Feature parity for data/resource/stream APIs
+### Phase 4 — Reliable data parity and unsupported API decision
-Objective: preserve current PeerConnectivity capabilities where possible.
+Objective: preserve reliable data/message behavior while explicitly bounding the current Network backend.
-Tasks:
+Decision:
+
+- Implement reliable `Data` and `PeerMessage` send parity.
+- Keep `sendDataStream` and `sendResourceAtURL` source-compatible and supported by the MultipeerConnectivity backend.
+- Do not implement a custom Network stream or resource-transfer protocol in this migration stack.
+- Keep `.receivedStream`, `.startedReceivingResource`, and `.finishedReceivingResource` available for MultipeerConnectivity; the Network backend does not emit them.
+- Treat a future Network-native file or streaming protocol as separately scoped work, not as a blocker for the current migration.
-1. Implement reliable data send parity.
-2. Decide whether unreliable send can be supported or should become best-effort over TCP/TLS.
-3. Implement resource transfer protocol with chunking and progress reporting.
-4. Evaluate stream API compatibility and document limitations.
-5. Add protocol-version negotiation for future compatibility.
+Rationale:
+
+Network framework has no one-to-one replacement for these MultipeerConnectivity APIs. Emulation would introduce new framing, flow-control, progress, cancellation, persistence, and protocol-versioning contracts beyond the reliable message transport targeted by this migration.
Acceptance criteria:
-- Existing public send APIs either work or have documented migration/deprecation path.
-- Resource transfer has tests for success, failure, and progress callbacks.
-- Any unsupported MC behavior is explicitly documented.
+- Reliable data and typed message APIs work on both backends.
+- Network calls to unsupported send APIs fail explicitly with documented behavior.
+- Documentation directs stream/resource consumers to remain on `.multipeerConnectivity`.
+- Unsupported receive events are documented as MultipeerConnectivity-only.
### Phase 5 — Public API migration and deprecations
@@ -185,7 +192,7 @@ Objective: guide consumers away from MC-specific API.
Tasks:
1. Add framework-neutral public accessors where needed.
-2. Deprecate `multipeerSession: MCSession` if retaining it blocks Network-backed operation.
+2. Deprecate `multipeerSession: MCSession` if retaining it blocks Network-backed operation. Do not deprecate stream/resource APIs solely because they are MultipeerConnectivity-only; they remain valid on that backend.
3. Add initializer/configuration to choose `.multipeerConnectivity` vs `.networkFramework` backend for a non-breaking transition release.
4. Decide whether already-deprecated `sendEvent(_:toPeers:)` and related legacy event observation APIs are removed in a major-version migration or retained through adapters.
5. Document deployment target changes and Info.plist requirements.
@@ -197,22 +204,22 @@ Acceptance criteria:
- MC-specific public API is marked deprecated before removal.
- Migration guide explains breaking changes and fallback behavior.
-### Phase 6 — PeerConnectivityUI replacement
+### Phase 6 — App-owned browser UI foundation
-Objective: replace `MCBrowserViewController` usage.
+Objective: support Network peer selection without prematurely standardizing reusable UI behavior.
-Tasks:
+Decision:
-1. Build a custom browser UI backed by framework-neutral discovery events.
-2. Rework `PeerConnectionManager+UI.swift` so browser UI no longer requires direct `multipeerSession` access for Network-backed managers.
-3. Preserve existing PeerConnectivityUI product structure.
-4. Add iOS-only tests where feasible.
-5. Deprecate or remove MC browser assistant after replacement exists.
+- Use the framework-neutral `PeerBrowserModel` as the supported replacement foundation for Network-backed apps.
+- Keep rendering, selection, cancellation, progress, errors, accessibility, and presentation app-owned.
+- Preserve the existing `MCBrowserViewController` path for the MultipeerConnectivity backend.
+- Defer a reusable SwiftUI or UIKit browser until app integrations establish common requirements.
Acceptance criteria:
-- UI package no longer depends on `MCBrowserViewController` for Network backend.
-- Existing apps can browse/select peers with Network implementation.
+- Network-backed apps can observe discovered peers and connection status, then invite an app-approved peer through `PeerBrowserModel`.
+- Documentation includes an app-owned UI example and clearly states that reusable browser UI is deferred.
+- No default backend or MultipeerConnectivity browser behavior changes.
## Testing Plan
@@ -252,23 +259,14 @@ xcodebuild test -workspace PeerConnectivity.xcworkspace \
- Backend selection/deprecation notes.
- Known limitations vs MultipeerConnectivity.
-## Open Questions
+## Resolved decisions and remaining gates
-1. Should Network framework be introduced as an opt-in backend first, or should it replace MC internally once stable?
-2. What is the minimum supported OS after migration?
-3. Is preserving `multipeerSession: MCSession` required for a transition release?
-4. Should unreliable send semantics be preserved, deprecated, or documented as best-effort?
-5. Is stream/resource transfer heavily used by consumers, or can those APIs be deprecated?
-6. What service type naming convention should be required for Bonjour compatibility?
-7. What authentication model should be default: no TLS identity, PSK, certificate identity, or app-provided verifier?
-8. When should Network connection policy values become public configuration instead of fixed internal defaults?
-
-## Immediate Next Step
-
-Implement Phase 1's transport seam and mock-backed tests in this branch:
-
-```text
-feature/network-framework-migration
-```
+- Network was introduced as an explicit opt-in; MultipeerConnectivity remains the default.
+- Minimum deployment targets are iOS 13 and macOS 10.15.
+- `multipeerSession` remains available during dual-backend transition and must be deprecated before MC removal.
+- Bare service types map to `_._tcp`; already-qualified TCP Bonjour types are preserved.
+- Stream/resource parity is intentionally outside this migration stack; those APIs remain MC-only.
+- Connection policy remains fixed/internal until device and load evidence demonstrates a need for public tuning.
+- Individual peer authentication remains unresolved and is required before stable/default status.
-Keep this first commit behavior-preserving and small so later Network framework work can build on a stable abstraction layer.
+The next work is not another unconditional migration phase. Select follow-ups according to [NetworkMigrationReadinessAudit.md](NetworkMigrationReadinessAudit.md): security identity, error observability, resource bounds, and physical-device evidence come before stable status; default switching and MC removal have later, separate gates.
diff --git a/NetworkMigrationPRPlan.md b/NetworkMigrationPRPlan.md
index dcd4534..b042f16 100644
--- a/NetworkMigrationPRPlan.md
+++ b/NetworkMigrationPRPlan.md
@@ -64,13 +64,14 @@ Acceptance criteria:
- Complete bidirectional reliable data parity for `PeerMessage` use cases.
- Add handshake timeout, idle timeout, connection caps, and discovery caps.
- Keep policy values internal until their defaults are validated across CI and device testing.
-- Keep TLS enabled and add app-configurable identity or PSK verification before public Network use.
+- Keep TLS enabled. Treat TLS-PSK as group-membership authentication only, and follow [NetworkTrustModelPlan.md](NetworkTrustModelPlan.md) before claiming individual peer identity.
- Add malformed-frame, oversized-frame, timeout, and cap tests.
### PR 6: Public API migration and deprecations
- Add framework-neutral public accessors where MC types currently leak through.
-- Deprecate MC-specific APIs that block a complete migration, including `multipeerSession`, stream APIs, resource APIs, and browser-controller APIs where needed.
+- Deprecate MC-specific APIs only where they block source-compatible Network-backed operation, such as `multipeerSession` or browser-controller APIs where needed.
+- Keep `sendDataStream`, `sendResourceAtURL`, and their receive events available for MultipeerConnectivity consumers; document them as unsupported by the current Network backend rather than deprecating them.
- Document replacement paths and compatibility behavior.
### PR 7: Simplification/refactor pass
@@ -80,18 +81,18 @@ Acceptance criteria:
- Require a clear justification for any new layer that remains.
- Prefer net LOC reduction unless tests or docs intentionally increase coverage.
-### PR 8: SwiftUI browser replacement
+### PR 8: App-owned browser UI foundation
-- Add `PeerBrowserView` and `PeerBrowserModel` driven by framework-neutral discovery and connection events.
-- Use SwiftUI as the primary browser implementation.
-- Provide UIKit bridging through `UIHostingController` or representable wrappers where compatibility requires it.
-- Retain and deprecate old `MCBrowserViewController`-specific paths as compatibility shims only.
-- Add SwiftUI/UI tests for peer listing, selection, cancellation, and connection state updates.
+- Use `PeerBrowserModel`, driven by framework-neutral discovery and connection events, as the supported Network browser foundation.
+- Document a concise integration pattern for app-owned peer lists and approval flows.
+- Defer a reusable `PeerBrowserView` and UIKit bridge until app integrations establish shared requirements.
+- Retain the existing `MCBrowserViewController` path for the MultipeerConnectivity backend.
+- Do not change the default backend or add transport/trust behavior in this UI decision.
-### PR 9: End-to-end automation and UI testing
+### PR 9: End-to-end automation and demo testing
- Add a loopback/local integration harness where possible.
-- Add XCUITest coverage for the SwiftUI browser and demo app smoke paths.
+- Add UI smoke coverage for the demo's app-owned browser flow when stable automation is practical.
- Add CI automation for stable smoke tests.
- Document manual device checks for Bonjour and Local Network privacy prompts.
@@ -99,8 +100,26 @@ Acceptance criteria:
- Update README, migration guide, Info.plist notes, backend-selection docs, and CHANGELOG.
- Document unsupported or deprecated MC-specific APIs.
+- Record the current parity boundary: Network supports reliable `Data`/`PeerMessage` transport, while stream/resource send APIs and receive events remain MultipeerConnectivity-only.
- Prepare versioning notes for the deployment-target bump and Network backend opt-in.
+### Final planned PR: migration readiness audit
+
+- Reconcile the implemented stack, accepted non-parity, and remaining production evidence.
+- Define separate gates for stable opt-in, default-backend selection, and MultipeerConnectivity removal.
+- Classify optional follow-ups by the first readiness level that requires them.
+- Record release/versioning constraints and a risk register without changing runtime behavior.
+
+The resulting [NetworkMigrationReadinessAudit.md](NetworkMigrationReadinessAudit.md) is authoritative when this historical sequencing plan and the implemented stack differ. It confirms that no optional implementation is needed to merge the experimental opt-in stack, while individual authentication, error observability, resource bounds, and physical-device evidence are required before stable status.
+
+### Future security slice: individual peer identity
+
+- Select one trust mode from [NetworkTrustModelPlan.md](NetworkTrustModelPlan.md) only after focused security and platform review.
+- Keep authenticated principal, stable transport identifier, and mutable display name distinct.
+- Bind the accepted principal to the connection before connected/data events or duplicate resolution trust that identity.
+- Add spoofing, mismatch, replay, revocation, downgrade, and verifier-failure tests in the same implementation PR.
+- Do not combine this work with a default-backend change or a general public policy API.
+
## Required Security Gates
Security review is mandatory for PRs that change:
@@ -108,7 +127,7 @@ Security review is mandatory for PRs that change:
- Network listener/connection setup.
- TLS identity, PSK, trust evaluation, or handshake payloads.
- Untrusted input parsing, frame decoding, or peer identity validation.
-- SwiftUI browser input, peer selection, or invite flows.
+- Reusable browser input, peer selection, or invite flows.
- Public backend selection or default backend behavior.
-Before the Network backend is publicly selectable, peer authentication must be explicit rather than relying on self-asserted display names or unauthenticated handshakes.
+The Network backend is already publicly selectable as an experimental opt-in. Before it is described as production-ready or made the default, individual peer authentication must be explicit rather than relying on a group PSK, self-asserted display names, or unauthenticated handshakes; follow the gates in [NetworkTrustModelPlan.md](NetworkTrustModelPlan.md).
diff --git a/NetworkMigrationReadinessAudit.md b/NetworkMigrationReadinessAudit.md
new file mode 100644
index 0000000..130b8b8
--- /dev/null
+++ b/NetworkMigrationReadinessAudit.md
@@ -0,0 +1,159 @@
+# Network Migration Readiness Audit
+
+## Decision
+
+As of the PR #49 stack base (`feature/network-migration-local-network-notes`), `.networkFramework` is a useful **experimental opt-in** for bounded local `Data` and `PeerMessage` evaluation. It is **not ready to be described as stable, made the default, or used to remove MultipeerConnectivity**.
+
+No runtime merge blocker was found for the current opt-in stack. The remaining gates below belong to later, separately reviewed PRs; this audit does not authorize a backend-default change.
+
+This document is the authoritative readiness checklist. The earlier [migration plan](NetworkFrameworkMigrationPlan.md) records the staged architecture, the [PR plan](NetworkMigrationPRPlan.md) records how the stack was decomposed, the [backend guide](NetworkBackendGuide.md) describes current adopter behavior, and the [trust plan](NetworkTrustModelPlan.md) defines the security boundary.
+
+## Readiness levels
+
+These decisions are separate:
+
+1. **Experimental opt-in (current):** adopters explicitly select `.networkFramework` and accept documented limitations.
+2. **Stable opt-in:** the project supports the Network backend as a production API for its documented capability set, but does not select it implicitly.
+3. **Default:** existing initializer call sites select Network unless they explicitly request MultipeerConnectivity.
+4. **MultipeerConnectivity removal:** MC implementation and public compatibility surfaces are deleted in a breaking release.
+
+A later level inherits every gate from the preceding levels. “Parity” means parity for the declared reliable-message product, not implementation of every MC feature.
+
+## Completed capabilities
+
+Verified against `Sources`, `PeerConnectivityTests`, and the current stacked documentation:
+
+- [x] The package and podspec support iOS 13+ and macOS 10.15+; the iOS-only Xcode project targets iOS 13+.
+- [x] Transport seams and framework-neutral `PeerIdentity` allow `PeerConnectionManager` to construct either backend.
+- [x] `.multipeerConnectivity` remains the initializer default; `.networkFramework` is explicit opt-in and availability-checked.
+- [x] Network Bonjour advertising/discovery maps bare service names such as `local` to `_local._tcp` and publishes bounded versioned identity metadata.
+- [x] `.automatic` discovery/connection and `.custom` app-owned selection are wired. `PeerBrowserModel` provides a main-queue model for app-owned UI.
+- [x] TCP message framing handles partial/coalesced input, rejects unknown/oversized frames, and caps a payload at 1 MiB.
+- [x] Reliable `Data` and typed `PeerMessage` exchange, targeted sends, broadcasts, duplicate-connection resolution, disconnect, and reconnect paths are implemented.
+- [x] Coordinator state is serialized and currently bounds handshakes to 10 seconds, pending connections to 16, and connected peers to 8.
+- [x] `.preSharedKey` enables external-PSK transport pinned to TLS 1.2 as both the minimum and maximum, with no fallback to another TLS version or plaintext; `.unauthenticated` is explicitly documented as a separate plaintext diagnostics/migration compatibility mode.
+- [x] Observable/listener mutation is synchronized and concurrency tests cover registration, removal, and delivery.
+- [x] Unit tests cover protocol parsing, state mapping, duplicate handling, caps, timeout behavior, transport adapters, manager routing, and browser-model behavior.
+- [x] Opt-in loopback coverage exercises discovery, service isolation, bidirectional/large typed messages, multi-peer broadcast, PSK mismatch, and reconnect. CI isolates and retries these real Network/Bonjour tests.
+- [x] Adopter documentation covers backend selection, local-network privacy metadata, service mapping, device/network caveats, PSK handling, demo usage, and manual physical-device validation.
+
+## Known non-parity and accepted boundaries
+
+| Area | Current Network behavior | Readiness effect |
+|---|---|---|
+| Streams/resources | `sendDataStream` throws; resource sends return `nil` and complete with an error; receive events are not emitted. | Accepted for stable reliable-message scope. Before MC removal, either replace these APIs or remove them in a documented breaking release. |
+| Browser UI | No `MCBrowserViewController` or advertiser-assistant equivalent; apps use `PeerBrowserModel` and app-owned UI. | Accepted. A reusable Network UI is optional, not a stable/default/removal gate. |
+| Invitation semantics | Network `invitePeer` ignores `context` and its caller-supplied `timeout`; `.inviteOnly` has no MC invitation dialog/handler equivalent, and inbound Network connections are transport-accepted subject to protocol/security checks. | Must be made explicit in the stable API contract. Implement equivalent approval semantics only if supported products require them. |
+| Public MC session | `multipeerSession` remains public and traps when used with Network. | Tolerable only during dual-backend transition; deprecate and replace before MC removal. |
+| Security certificate event | `.receivedCertificate` reflects MC delegate behavior and has no Network equivalent. | Document as MC-only before stable; deprecate/remove or replace before MC removal. |
+| Discovery/start failures | MC startup failures become `.error`; current Network listener/browser state failures are not forwarded through that public path. | Blocking for stable: permission, listener, browser, and connection failures need an observable, tested contract. |
+| Send outcomes | Public `sendData`/`sendMessage` do not report asynchronous send failure on either backend; Network send completions are currently discarded. | Define and test the stable support contract. Add an outcome API if production requirements cannot tolerate best-effort caller visibility. |
+| Identity | Bonjour and handshake identifiers/display names are self-asserted. A group PSK authenticates only possession of the shared group key. | Blocking for stable/default. Do not authorize or audit by `Peer` identity today. |
+| Policy | Handshake and connection caps are fixed/internal; there is no idle timeout or discovery cap. | Validate defaults and add missing resource bounds before stable use on untrusted/local hostile networks. Public tuning is required only if validated products need it. |
+| Device evidence | Automated Network tests are local loopback/simulator-oriented; the documented physical-device matrix has not been completed by this stack. | Blocking for stable/default. |
+
+## Gate: stable opt-in
+
+Do not remove the “experimental/beta” label until every item is complete:
+
+### Security and protocol
+
+- [ ] Select and implement one individually authenticated trust mode from `NetworkTrustModelPlan.md`.
+- [ ] Bind authenticated principal, stable transport identifier, and mutable display name before registration, connected/data events, or duplicate resolution.
+- [ ] Fail closed and add spoofing, identity-mismatch, replay, stale/revoked credential, downgrade, timeout, cancellation, and verifier-error tests.
+- [ ] Complete focused security and privacy review with no unresolved high-severity findings.
+- [ ] Version the handshake/trust negotiation and document compatibility and downgrade behavior.
+
+### Reliability and operations
+
+- [ ] Forward Network browser, listener, permission-related, and connection failures through a documented public error/state contract.
+- [ ] Add discovery bounds and idle-connection lifecycle policy, or document evidence that an alternate bound closes those resource-exhaustion paths.
+- [ ] Validate the 10-second/16-pending/8-connected defaults under expected load, churn, backgrounding, and hostile discovery. Expose configuration only where evidence establishes a consumer need.
+- [ ] Decide whether reliable-message send completion/failure needs public API; test whichever contract is selected.
+- [ ] Run soak/churn tests for repeated start/stop, background/foreground, peer loss, duplicate races, malformed traffic, and capacity recovery.
+
+### Product and validation
+
+- [ ] Freeze the supported capability matrix, including invitation/context/timeout behavior and MC-only events.
+- [ ] Complete the `NetworkBackendGuide.md` physical-device checklist on the release build across the supported iOS/macOS versions, device classes, infrastructure Wi-Fi, required peer-to-peer topology, Local Network allow/deny/recovery, and production-equivalent key provisioning.
+- [ ] Record results and establish a repeatable release regression matrix; simulator/loopback success alone is insufficient.
+- [ ] Validate at least one real adopting app using app-owned discovery UI, lifecycle handling, and production topology.
+- [ ] Publish troubleshooting and compatibility guidance for blocked Bonjour, VPN/firewall/managed-network policy, and permission denial.
+
+## Gate: default backend
+
+In addition to all stable-opt-in gates:
+
+- [ ] Collect at least one stable release cycle of opt-in production/device evidence with no unresolved critical regressions.
+- [ ] Define a secure default initialization story. The current default `networkSecurity: .unauthenticated` cannot accompany an implicit Network backend switch.
+- [ ] Decide how existing source-compatible initializer calls obtain/provision trust material, or require an explicitly breaking initializer migration.
+- [ ] Publish a migration guide for changed discovery, `.inviteOnly`, browser UI, stream/resource, certificate, identity, error, and networking behavior.
+- [ ] Audit all examples and demo paths so no default path silently uses plaintext transport.
+- [ ] Treat the switch as a breaking behavioral release, with an explicit `.multipeerConnectivity` rollback option for at least one transition release.
+- [ ] Verify release telemetry/support ownership and rollback criteria before changing the factory default.
+
+## Gate: MultipeerConnectivity removal
+
+In addition to the default-backend gates:
+
+- [ ] Deprecate `multipeerSession` and all other MC-specific public behavior in a released transition version, with framework-neutral replacements where retained behavior needs them.
+- [ ] Resolve stream/resource APIs and receive events: implement a separately versioned Network protocol or remove them with major-version migration notes. They are not required to make Network stable/default, but unresolved APIs block MC removal.
+- [ ] Resolve `MCBrowserViewController`, advertiser-assistant, certificate-event, invitation-context, and invitation-handler compatibility. A reusable browser is not mandatory if app-owned UI is the declared replacement.
+- [ ] Decide the fate of legacy `sendEvent`/event-observation APIs independently of transport removal; do not conflate existing deprecation with Network parity.
+- [ ] Remove MC adapters, imports, `MCPeerID` storage/bridges, `MCSession` exposure, UI product dependencies, and podspec framework linkage; prove the core and UI products build without `MultipeerConnectivity.framework`.
+- [ ] Add source/API migration tests or fixtures for the supported replacement surface and verify no shipped target links MC.
+- [ ] Publish the removal only in a breaking major release after the announced deprecation window.
+
+## Optional PR #50+ disposition
+
+No optional implementation is required to merge this documentation audit or the experimental opt-in stack.
+
+| Follow-up | When required |
+|---|---|
+| Individual peer authentication and negative security suite | **Before stable**, therefore also before default/removal. |
+| Network error/permission observability | **Before stable**. |
+| Discovery cap, idle lifecycle bound, soak/churn/device matrix | **Before stable**. |
+| Public policy configuration | **Later only if validation shows adopters need tuning**; fixed validated defaults are acceptable. |
+| Public send-result API | **Later if the frozen production contract requires caller-visible delivery failure**; the decision itself is required before stable. |
+| Reusable SwiftUI/UIKit Network browser | **Optional**; not required for stable, default, or removal if app-owned UI remains the product decision. |
+| Network stream/resource protocol | **Optional before stable/default**; required before removal only if those APIs are retained. A major-release API removal is the alternative. |
+| Invitation context/timeout or inbound approval parity | **Product-dependent**; freeze/document before stable, implement before default/removal only if the supported contract promises it. |
+| Default backend switch | **Only after stable gates and transition evidence**; not part of the current stack. |
+| MC adapter/API deletion | **Only after deprecation and breaking-release gates**. |
+
+## Release and versioning notes
+
+- Release the current stack as an opt-in experimental/beta feature, not as production parity. Keep `.multipeerConnectivity` as the default.
+- Call out the already-applied minimum-platform increase to iOS 13/macOS 10.15 in release notes. CocoaPods remains legacy and does not carry `PeerConnectivityUI`; SwiftPM is the primary distribution path.
+- Treat protocol/trust negotiation as versioned wire behavior. Do not silently reinterpret protocol version 1 peers when individual identity is introduced.
+- A stable opt-in can ship in a feature release if its API remains additive and the experimental contract reserved change; document the security and behavior transition prominently.
+- Changing the default is behaviorally breaking even if source-compatible. Removing MC types/APIs or stream/resource behavior is source/ABI breaking and requires a major release plus a deprecation window.
+- Update `CHANGELOG.md`, README support tables, package/podspec metadata, generated API docs, and adopting-app plist guidance in each release PR; do not add unreleased promises to the historical changelog now.
+
+## Risk register
+
+| Risk | Severity now | Current control | Closure gate |
+|---|---|---|---|
+| Group member spoofs identifier/display name | Critical for identity-based authorization | Experimental label, PSK boundary documentation | Individual identity binding and adversarial security tests before stable |
+| Default initializer would select plaintext Network transport | Critical if default switched | MC remains default | Secure initialization/provisioning design before default |
+| Local Network denial or listener/browser failure is not surfaced consistently | High | Manual guide; MC error event path exists | Public Network error/state propagation before stable |
+| Unbounded discovery and no idle timeout enable resource pressure | High on hostile local networks | 16 pending / 8 connected / 10-second handshake bounds | Add/justify bounds and load testing before stable |
+| Device/AWDL/topology behavior differs from loopback/simulator | High | `includePeerToPeer`, isolated E2E CI, manual checklist | Recorded physical-device regression matrix before stable |
+| MC-only stream/resource consumers break on switch/removal | High | Explicit unsupported errors and MC default | Migration plan before default; replacement or breaking removal before MC deletion |
+| `.inviteOnly` implies approval semantics Network does not provide | High for approval-dependent apps | App-owned UI guidance | Freeze contract and validate adopting app before stable/default |
+| Asynchronous send failures are invisible to callers | Medium–high | TCP/TLS and connection-state cleanup | Decide/test contract before stable; add API if required |
+| Internal policy defaults do not fit larger meshes | Medium | Conservative fixed caps and unit tests | Device/load evidence; public configuration only if needed |
+| Bonjour, VPN, firewall, guest/managed network, or permission policy blocks operation | Medium | Production setup and troubleshooting caveats | Device/topology validation and actionable errors before stable |
+| Protocol evolution strands or downgrades peers | Medium–high | Handshake version rejects unsupported versions | Versioned trust negotiation and interoperability tests before stable |
+| Removing MC breaks public `MCSession`, UI, certificate, and transfer surfaces | High | Dual backends retained | Deprecation window and major release before removal |
+
+## Audit evidence
+
+Claims in this checklist were reconciled against:
+
+- `Sources/PeerConnectionManager.swift`, `PeerConnectionTransports.swift`, `NetworkPeerTransport.swift`, `NetworkPeerTransportAdapters.swift`, `NetworkPeerCoordinator.swift`, `PeerNetworkProtocol.swift`, `PeerBrowserModel.swift`, and `PeerConnectionResponder.swift`;
+- the complete `PeerConnectivityTests` suite and `.github/workflows/ci.yml` test split;
+- `Package.swift`, `PeerConnectivity.podspec`, and the Xcode project deployment settings; and
+- `NetworkFrameworkMigrationPlan.md`, `NetworkMigrationPRPlan.md`, `NetworkBackendGuide.md`, and `NetworkTrustModelPlan.md`.
+
+The automated test commands verify the current implementation; they do not satisfy the unchecked physical-device, security-design, production-adoption, or release-transition gates above.
diff --git a/NetworkTrustModelPlan.md b/NetworkTrustModelPlan.md
new file mode 100644
index 0000000..5dd381a
--- /dev/null
+++ b/NetworkTrustModelPlan.md
@@ -0,0 +1,137 @@
+# Network Backend Trust Model Plan
+
+## Status and scope
+
+This document defines the security boundary of the experimental Network framework backend and the requirements for strengthening it. It is a plan, not a production authentication implementation. It does not change the default `.multipeerConnectivity` backend, add a transport, or define a public trust-policy API.
+
+## Current security boundary
+
+The Network backend has two modes:
+
+- `.unauthenticated` is plaintext TCP and provides neither confidentiality nor peer authentication.
+- `.preSharedKey` passes one app-provided key and the fixed PSK identity label `PeerConnectivity.NetworkFramework.PSK.v1` to `sec_protocol_options_add_pre_shared_key` on both listener and outbound connections. Apple's external PSK API supports PSK negotiation only in TLS 1.2, so the transport sets both its minimum and maximum protocol versions to TLS 1.2. Negotiation cannot fall back to another TLS version or to plaintext.
+
+Apple's Security framework describes the external-PSK inputs as the PSK and its PSK identity. A PSK identity is a **label for a key**, not the authenticated identity of the endpoint using that key. The fixed PeerConnectivity label therefore selects the protocol's shared key; it does not identify Alice, Bob, a device, or an installation.
+
+A successful current TLS-PSK connection establishes only that the remote endpoint knows the same group secret. It provides encrypted, integrity-protected transport against parties outside that group. It does **not** establish which individual group member is connected.
+
+The following values remain self-asserted:
+
+- Bonjour TXT `pc-id` and `pc-name`, which are visible before connection and used for discovery/routing.
+- `PeerNetworkHandshake.identity.identifier` and `.displayName`, which are sent after transport setup but are not cryptographically bound to an individual key.
+- Public `Peer.displayName`, which reflects the handshake identity after connection.
+
+TLS protection prevents a non-member from modifying a connected member's handshake in transit, but every holder of the group PSK can create its own valid TLS connection and assert any identifier or display name.
+
+## PSK requirements
+
+Apps using `.preSharedKey` should meet all of these requirements:
+
+1. **Generate at least 256 random bits (32 bytes)** with a cryptographically secure random-number generator. This is a conservative project requirement above TLS's baseline security level.
+2. Do not use passwords, passphrases, display names, service names, UUID text, predictable tokens, or demo strings directly as PSKs. Low-entropy external PSKs can permit offline dictionary attacks against an observed handshake; pinning this transport to TLS 1.2 does not make password-like key material safe.
+3. Provision the key over an authenticated channel and store it using platform-appropriate protected storage. Do not embed a production group key in source, examples, logs, Bonjour metadata, or the application bundle.
+4. Scope a key to one app/environment and one intended authorization group. Do not reuse it across unrelated protocols, production and test environments, or groups with different privileges.
+5. Rotate the key when membership changes or compromise is suspected. Group rotation removes future access but cannot identify which member used a previously shared key and does not provide post-compromise security for later handshakes while the old key remains valid.
+6. Treat every holder and every process able to read the PSK as equally authorized under the current model.
+
+An app that starts from a password needs a purpose-built, reviewed password-authenticated provisioning design. HKDF does not increase source entropy and must not be used to present a password as a high-entropy PSK.
+
+## Threat model: display-name spoofing inside a PSK group
+
+### Adversary
+
+Assume an attacker is a current or former group member who still knows the PSK, or has compromised one member and extracted it. The attacker can browse and advertise on the local network, initiate and accept TLS-PSK connections, and choose arbitrary valid discovery and handshake fields.
+
+### Attack
+
+The attacker advertises `pc-name=Alice` and either copies Alice's observed `pc-id` or chooses another identifier. After completing TLS with the shared group key, it sends a handshake that claims the same identity. The current coordinator accepts the handshake identity after protocol/version checks; no individual credential proves that the claimant is Alice.
+
+### Impact
+
+- UI and logs can attribute attacker-controlled messages to the wrong human or device.
+- Name-based approval, authorization, audit, or safety decisions can be bypassed.
+- Copying an identifier can interact with discovery maps and duplicate-connection resolution, causing confusion or availability loss for the legitimate peer.
+- A unique-looking identifier does not solve the problem when it is self-asserted; uniqueness and authentication are separate properties.
+
+### What remains protected
+
+A network attacker without the PSK cannot complete the PSK-authenticated TLS connection or read/modify its application data. This does not reduce the insider threat because every PSK holder has the credential needed to create an independently valid connection.
+
+### Required application posture today
+
+Treat `Peer.displayName` as presentation text and `PeerIdentity.identifier` as a transport-local correlation value, not as an authorization principal. Do not grant privileges, approve sensitive actions, or create audit claims from either value alone. Apps requiring individual accountability should not use the current Network backend for sensitive operations until they add an independently reviewed identity layer or PeerConnectivity implements one of the modes below.
+
+## Future trust options
+
+These options are candidates, not promised APIs. A future design may support more than one because deployments have different provisioning and recovery needs.
+
+### 1. HKDF-derived scoped or per-peer keys
+
+Derive independent keys from a high-entropy root using HKDF with explicit, versioned context such as app identifier, environment, service type, group identifier, role, and canonical peer identifiers. Use authenticated, unambiguous context encoding and domain-separated labels.
+
+Benefits:
+
+- Limits accidental key reuse across services/environments.
+- A distinct pairwise key can authenticate membership in a specific pair instead of an entire group.
+- Supports targeted rotation when provisioning can distribute pairwise material.
+
+Limits and design work:
+
+- HKDF does not create identity or entropy; the root and the mapping from identifiers to derived keys must already be trusted.
+- Deriving from self-asserted Bonjour/handshake identifiers would not provide identity binding.
+- Both endpoints need an authenticated way to know the expected peer identity before choosing the key. Apple's PSK-selection callback can select among PSK identities, but the selection label is not itself proof of the endpoint's human/device identity.
+- The protocol must specify salt, `info`, output length, canonical ordering for pairwise identifiers, versioning, key identifiers, storage, rotation, and migration behavior.
+
+### 2. Per-peer identity binding above TLS
+
+Give each installation or account a long-term signing key. During connection setup, exchange a public-key credential and sign a transcript containing at least both claimed peer identifiers, both fresh nonces, protocol/service context, and a binding to the established TLS channel where platform support permits. Accept the peer only after verifying the signature and app trust policy.
+
+This can work over group TLS-PSK while adding individual identity, but it requires replay protection, downgrade protection, credential provisioning/revocation, secure private-key storage, and a precise connection-state gate so application data is not attributed before verification succeeds. Merely signing the display name is insufficient.
+
+### 3. Certificate and pinning mode
+
+Configure a local `sec_identity_t` containing a private key and certificate with `sec_protocol_options_set_local_identity`, then evaluate the peer trust with `sec_protocol_options_set_verify_block`. A deployment could use an app-specific CA, pinned certificate/public-key hashes, or another explicit trust-anchor policy.
+
+The mode must define mutual authentication rather than assuming server-only validation is enough for a symmetric peer mesh. It also needs issuance, expiry, rotation, revocation, pin-set updates, recovery, and a rule binding the accepted certificate/public key to the protocol peer identifier. Disabling default trust evaluation without replacing it with a complete pinning policy would be insecure.
+
+### 4. App-provided verifier
+
+Allow an app to evaluate a structured peer credential or challenge result and return an authenticated principal (or reject) before a connection becomes visible as connected. This supports account systems, managed-device attestations, invitation credentials, or app-specific trust stores without forcing one PKI model into the framework.
+
+A future verifier contract must specify:
+
+- the authenticated inputs it receives, including channel-binding material if available;
+- asynchronous completion, timeout, cancellation, and exactly-once semantics;
+- the queue/executor used for callbacks;
+- fail-closed behavior for errors and missing decisions;
+- whether decisions are cached and how revocation invalidates them;
+- separation of authenticated principal, mutable display name, and transport identifier;
+- inbound and outbound symmetry; and
+- when discovery/connection/data events become observable.
+
+This is intentionally not a public API proposal yet.
+
+## Recommended direction and security gates
+
+1. Keep `.multipeerConnectivity` as the default and keep the Network backend experimental.
+2. Immediately document 32-byte CSPRNG-generated PSKs and the group-membership boundary; do not imply that a PSK identity label identifies a peer.
+3. Before calling the Network backend production-ready or making it the default, choose and security-review an individual identity mode. Pairwise HKDF-derived PSKs are suitable only where authenticated pair provisioning already exists; certificate/pinning or signed per-peer credentials provide clearer long-term identity for broader deployments.
+4. Separate three concepts in the eventual protocol model: authenticated principal, stable transport identifier, and mutable display name.
+5. Bind the accepted principal to the connection and require verification before registering the peer, emitting connected/data events, or using the identity in duplicate resolution.
+6. Add negative tests for member spoofing, credential mismatch, replay, stale/revoked credentials, downgrade, verifier timeout/error, and identity changes across discovery and handshake.
+7. Require a security review and on-device interoperability tests before exposing any new trust mode as stable public policy.
+
+## Source and platform verification
+
+The claims above were checked against:
+
+- [`Sources/NetworkPeerTransport.swift`](Sources/NetworkPeerTransport.swift): current PSK setup uses `sec_protocol_options_add_pre_shared_key` with one app key and a fixed protocol label, and pins both the minimum and maximum protocol versions to TLS 1.2 with no protocol or plaintext fallback.
+- [`Sources/PeerNetworkProtocol.swift`](Sources/PeerNetworkProtocol.swift): Bonjour and handshake identities contain self-asserted identifier/display-name fields and no proof of possession.
+- [`Sources/NetworkPeerCoordinator.swift`](Sources/NetworkPeerCoordinator.swift): a valid protocol handshake identity is registered without an individual credential check.
+- Apple SDK `Security.framework/Headers/SecProtocolOptions.h`: `sec_protocol_options_add_pre_shared_key` accepts a PSK plus its PSK identity; the same API surface provides PSK selection, local certificate identity, and trust verification callbacks.
+- [TLS 1.3, RFC 8446 §2.2](https://www.rfc-editor.org/rfc/rfc8446.html#section-2.2): external PSKs need sufficient entropy; password-derived/low-entropy secrets permit dictionary attacks.
+- [TLS 1.3, RFC 8446 §4.2.11](https://www.rfc-editor.org/rfc/rfc8446.html#section-4.2.11): a PSK identity is a label for a key.
+- [TLS 1.3, RFC 8446 Appendix E.7](https://www.rfc-editor.org/rfc/rfc8446.html#appendix-E.7): avoid cross-protocol PSK reuse.
+- [HKDF, RFC 5869 §§3–4](https://www.rfc-editor.org/rfc/rfc5869.html#section-3): bind derivation to context using `info`; HKDF cannot amplify password entropy.
+
+The TLS 1.3 references above inform general external-PSK key hygiene and future protocol design; they do not describe the current transport version. The current external-PSK transport requires exactly TLS 1.2. Apple header descriptions establish available platform mechanisms, not a complete PeerConnectivity protocol design. Each future option still needs focused platform prototyping and security review before implementation.
diff --git a/PeerConnectivity.xcodeproj/project.pbxproj b/PeerConnectivity.xcodeproj/project.pbxproj
index 80880d2..e79973e 100644
--- a/PeerConnectivity.xcodeproj/project.pbxproj
+++ b/PeerConnectivity.xcodeproj/project.pbxproj
@@ -45,6 +45,8 @@
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 */; };
+ 30BROWSERMODEL2608082 /* PeerBrowserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30BROWSERMODEL2608081 /* PeerBrowserModel.swift */; };
+ 30BROWSERMODELTEST2608082 /* PeerBrowserModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30BROWSERMODELTEST2608081 /* PeerBrowserModelTests.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 */; };
@@ -83,6 +85,8 @@
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 = ""; };
+ 30BROWSERMODEL2608081 /* PeerBrowserModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PeerBrowserModel.swift; path = Sources/PeerBrowserModel.swift; sourceTree = ""; };
+ 30BROWSERMODELTEST2608081 /* PeerBrowserModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerBrowserModelTests.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 = ""; };
@@ -147,6 +151,7 @@
3080C7E11D80A1D600AF9EA3 /* PeerAdvertiserAssisstantEventProducer.swift */,
3080C7E21D80A1D600AF9EA3 /* PeerAdvertiserEventProducer.swift */,
3080C7E31D80A1D600AF9EA3 /* PeerBrowser.swift */,
+ 30BROWSERMODEL2608081 /* PeerBrowserModel.swift */,
3080C7E41D80A1D600AF9EA3 /* PeerBrowserAssisstant.swift */,
3080C7E51D80A1D600AF9EA3 /* PeerBrowserEventProducer.swift */,
3080C7E61D80A1D600AF9EA3 /* PeerBrowserViewControllerEventProducer.swift */,
@@ -194,6 +199,7 @@
30NETADAPTTEST2607301 /* NetworkPeerTransportAdapterTests.swift */,
30BACKENDTEST2607301 /* PeerConnectionBackendTests.swift */,
30LOOPBACKTEST2608061 /* NetworkPeerLoopbackTests.swift */,
+ 30BROWSERMODELTEST2608081 /* PeerBrowserModelTests.swift */,
30PEERMSG2602020000000001 /* PeerMessageTests.swift */,
30SECURITY26060300000004 /* PeerSecurityConfigurationTests.swift */,
B20000022F30600000000002 /* ObservableTests.swift */,
@@ -333,6 +339,7 @@
30TRANS2606230000000002 /* PeerConnectionTransports.swift in Sources */,
3080C7F71D80A1D700AF9EA3 /* PeerBrowserViewControllerEventProducer.swift in Sources */,
3080C7F41D80A1D700AF9EA3 /* PeerBrowser.swift in Sources */,
+ 30BROWSERMODEL2608082 /* PeerBrowserModel.swift in Sources */,
3080C7F31D80A1D700AF9EA3 /* PeerAdvertiserEventProducer.swift in Sources */,
3080C7FC1D80A1D700AF9EA3 /* PeerSessionEventProducer.swift in Sources */,
3080C7F51D80A1D700AF9EA3 /* PeerBrowserAssisstant.swift in Sources */,
@@ -359,6 +366,7 @@
30NETADAPTTEST2607302 /* NetworkPeerTransportAdapterTests.swift in Sources */,
30BACKENDTEST2607302 /* PeerConnectionBackendTests.swift in Sources */,
30LOOPBACKTEST2608062 /* NetworkPeerLoopbackTests.swift in Sources */,
+ 30BROWSERMODELTEST2608082 /* PeerBrowserModelTests.swift in Sources */,
30PEERMSG2602020000000002 /* PeerMessageTests.swift in Sources */,
30SECURITY26060300000003 /* PeerSecurityConfigurationTests.swift in Sources */,
B20000022F30600000000001 /* ObservableTests.swift in Sources */,
diff --git a/PeerConnectivityDemo/Info.plist b/PeerConnectivityDemo/Info.plist
index e2499df..4cedb2a 100644
--- a/PeerConnectivityDemo/Info.plist
+++ b/PeerConnectivityDemo/Info.plist
@@ -44,7 +44,6 @@
NSBonjourServices
_local._tcp
- _local._udp
NSLocalNetworkUsageDescription
This app uses the local network to discover and connect with nearby devices for peer-to-peer communication.
diff --git a/PeerConnectivityDemo/README.md b/PeerConnectivityDemo/README.md
new file mode 100644
index 0000000..19211bd
--- /dev/null
+++ b/PeerConnectivityDemo/README.md
@@ -0,0 +1,39 @@
+# PeerConnectivity Demo
+
+The expanded demo exposes both transport backends without changing the library default. Choose **Multipeer** or **Network**, then choose **Automatic** or **Require Invitation** before starting. Both backends support both connection behaviors using the same public APIs. The demo remembers each backend's choice while it runs; defaults preserve the existing walkthroughs: Multipeer uses **Automatic**, while Network uses **Require Invitation**. All combinations retain the connection-mode controls, peer status, typed message history, raw data and resource exercises, structured logs, troubleshooting guidance, and physical-test checklist.
+
+## Connection behavior
+
+- **Automatic** creates the selected backend's manager with `.automatic`, so discovered peers connect automatically.
+- **Require Invitation** creates it with `.custom`. `PeerBrowserModel` supplies app-owned discovery state and the demo renders an explicit **Invite _peer name_** action for both backends.
+
+### Require-invitation flow
+
+The demo app target declares `NSLocalNetworkUsageDescription` and `_local._tcp` in `NSBonjourServices`, matching its `serviceType: "local"`. Adopting apps must add equivalent values for every service type to their own app target.
+
+1. Run the demo on two instances. Simulators are useful for a quick UI/API check; use two physical devices for Local Network permission and release-topology validation.
+2. Select the same backend and **Require Invitation** on both instances, then tap **Start**. When using Network on physical devices, allow Local Network access when prompted.
+3. Under **Peers**, tap the enabled **Invite _peer name_** action supplied from `PeerBrowserModel` discovery state.
+4. Wait for the peer to report **Connected**.
+5. Enter a message or ping, optionally choose a target, and tap **Send Typed Message**. The receiving instance records the `PeerMessage` in message history and the structured event log.
+
+The Network backend deliberately uses `.unauthenticated` transport and labels this in the UI; use it only for non-sensitive local migration testing. Production apps should provide app-managed `.preSharedKey` material and an appropriate trust model. Multipeer remains the default backend and retains the backward-compatible default `PeerSecurityConfiguration`.
+
+For physical-device testing, keep Wi-Fi enabled. The backend opts in to Apple peer-to-peer Wi-Fi, but Network.framework does not guarantee a particular interface or expose AWDL selection, and this is not a Bluetooth LE or Bluetooth-only transport. Same-Wi-Fi discovery can also be blocked by guest/client isolation, VPNs, firewalls, or managed-network policy. Follow the [production physical-device checklist](../NetworkBackendGuide.md#manual-physical-device-validation) before shipping.
+
+## Launch arguments
+
+| Argument | Effect |
+|---|---|
+| `PCNetworkBackend` | Select the Network.framework backend. Without it, MultipeerConnectivity remains selected. |
+| `PCAutoStart` | Start advertising and browsing after launch. |
+| `PCDisplayName ` | Use a deterministic local display name. |
+
+Example arguments for two instances:
+
+```text
+PCNetworkBackend PCAutoStart PCDisplayName Alice
+PCNetworkBackend PCAutoStart PCDisplayName Bob
+```
+
+The backend and connection-behavior selectors are disabled while networking is running. Tap **Stop** before changing either setting. Network mode does not support the demo resource exercise; that action logs the backend's explicit unsupported-operation error.
diff --git a/PeerConnectivityDemo/ViewController.swift b/PeerConnectivityDemo/ViewController.swift
index 440cd1a..ef4462a 100644
--- a/PeerConnectivityDemo/ViewController.swift
+++ b/PeerConnectivityDemo/ViewController.swift
@@ -32,23 +32,24 @@ class ViewController: UIViewController {
}
}
- fileprivate lazy var pcm : PeerConnectionManager = {
- let arguments = ProcessInfo.processInfo.arguments
- let requestedDisplayName = ViewController.argumentValue(for: "PCDisplayName") ?? ProcessInfo.processInfo.hostName
- let displayName = Peer.sanitizedDisplayName(requestedDisplayName)
- 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")
- pcm.observeMessages(ofType: DemoMessage.self, forKey: "demo.messages") { [weak self] message, peer in
- self?.handleDemoMessage(message, from: peer)
+ fileprivate enum ConnectionBehavior : String {
+ case automatic = "Automatic"
+ case requireInvitation = "Require Invitation"
+
+ fileprivate var connectionType : PeerConnectionType {
+ switch self {
+ case .automatic: return .automatic
+ case .requireInvitation: return .custom
+ }
}
- return pcm
- }()
+ }
+ fileprivate var pcm : PeerConnectionManager!
+ fileprivate var browserModel : PeerBrowserModel!
fileprivate var isNetworking = false
fileprivate var mode : ConnectionMode = .advertisingAndBrowsing
+ fileprivate var multipeerConnectionBehavior : ConnectionBehavior = .automatic
+ fileprivate var networkConnectionBehavior : ConnectionBehavior = .requireInvitation
fileprivate var discoveredPeers : [Peer] = []
fileprivate var connectedPeers : [Peer] = []
fileprivate var selectedTargetPeer : Peer?
@@ -70,11 +71,15 @@ class ViewController: UIViewController {
fileprivate let browsingBadgeLabel = UILabel()
fileprivate let connectedBadgeLabel = UILabel()
fileprivate let localPeerLabel = UILabel()
+ fileprivate let backendControl = UISegmentedControl(items: ["Multipeer", "Network"])
+ fileprivate let connectionBehaviorControl = UISegmentedControl(items: ["Automatic", "Require Invitation"])
+ fileprivate let backendDetailLabel = UILabel()
fileprivate let modeButton = UIButton(type: .system)
fileprivate let startStopButton = UIButton(type: .system)
fileprivate let refreshButton = UIButton(type: .system)
fileprivate let resetButton = UIButton(type: .system)
fileprivate let discoveredPeersLabel = UILabel()
+ fileprivate let inviteButtonsStack = UIStackView()
fileprivate let connectedPeersLabel = UILabel()
fileprivate let targetButton = UIButton(type: .system)
fileprivate let messageTextField = UITextField()
@@ -94,10 +99,12 @@ class ViewController: UIViewController {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = "Peer Demo"
+ backendControl.selectedSegmentIndex = ProcessInfo.processInfo.arguments.contains("PCNetworkBackend") ? 1 : 0
configureLayout()
configureActions()
+ configureManager()
refreshUI()
- appendLog(kind: "app.ready", detail: "Local peer: \(pcm.peer.displayName)")
+ appendLog(kind: "app.ready", detail: "Local peer: \(pcm.peer.displayName); backend: \(backendName)")
if ProcessInfo.processInfo.arguments.contains("PCAutoStart") {
startNetworking()
}
@@ -106,6 +113,7 @@ class ViewController: UIViewController {
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent || isBeingDismissed {
+ browserModel.stopObserving()
pcm.stop()
}
}
@@ -125,6 +133,63 @@ private extension ViewController {
return arguments[index + 1]
}
+ var selectedBackend : PeerConnectionBackend {
+ return backendControl.selectedSegmentIndex == 1 ? .networkFramework : .multipeerConnectivity
+ }
+
+ var isNetworkBackend : Bool {
+ return selectedBackend == .networkFramework
+ }
+
+ var backendName : String {
+ return isNetworkBackend ? "Network.framework" : "MultipeerConnectivity"
+ }
+
+ var selectedConnectionBehavior : ConnectionBehavior {
+ get {
+ return isNetworkBackend ? networkConnectionBehavior : multipeerConnectionBehavior
+ }
+ set {
+ if isNetworkBackend {
+ networkConnectionBehavior = newValue
+ } else {
+ multipeerConnectionBehavior = newValue
+ }
+ }
+ }
+
+ func configureManager() {
+ browserModel?.stopObserving()
+ pcm?.stop()
+ pcm?.removeAllListeners()
+
+ let requestedDisplayName = ViewController.argumentValue(for: "PCDisplayName") ?? ProcessInfo.processInfo.hostName
+ let displayName = Peer.sanitizedDisplayName(requestedDisplayName)
+ pcm = PeerConnectionManager(
+ serviceType: "local",
+ connectionType: selectedConnectionBehavior.connectionType,
+ displayName: displayName,
+ securityConfiguration: .default,
+ invitationPolicy: .acceptAll,
+ backend: selectedBackend,
+ networkSecurity: .unauthenticated
+ )
+ pcm.listenOn({ [weak self] event in
+ self?.handlePeerConnectionEvent(event)
+ }, withKey: "demo.events")
+ pcm.observeMessages(ofType: DemoMessage.self, forKey: "demo.messages") { [weak self] message, peer in
+ self?.handleDemoMessage(message, from: peer)
+ }
+
+ browserModel = PeerBrowserModel(manager: pcm) { [weak self] peers in
+ self?.discoveredPeers = peers
+ self?.refreshUI()
+ }
+ if selectedConnectionBehavior == .requireInvitation {
+ browserModel.startObserving()
+ }
+ }
+
func configureLayout() {
scrollView.translatesAutoresizingMaskIntoConstraints = false
contentStack.translatesAutoresizingMaskIntoConstraints = false
@@ -148,14 +213,18 @@ private extension ViewController {
contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
])
- [localPeerLabel, discoveredPeersLabel, connectedPeersLabel, troubleshootingLabel].forEach { $0.numberOfLines = 0 }
+ [localPeerLabel, backendDetailLabel, discoveredPeersLabel, connectedPeersLabel, troubleshootingLabel].forEach { $0.numberOfLines = 0 }
+ backendDetailLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
+ backendDetailLabel.textColor = .secondaryLabel
+ inviteButtonsStack.axis = .vertical
+ inviteButtonsStack.spacing = 8
configureMenuButton(modeButton)
configureMenuButton(targetButton)
configureMenuButton(logFilterButton)
configurePrimaryButton(startStopButton, title: "Start")
configureSecondaryButton(refreshButton, title: "Refresh")
configureSecondaryButton(resetButton, title: "Reset Demo")
- configurePrimaryButton(sendButton, title: "Send Message")
+ configurePrimaryButton(sendButton, title: "Send Typed Message")
configureSecondaryButton(rawDataButton, title: "Send Raw Data Ping")
configureSecondaryButton(resourceButton, title: "Send Demo Resource")
configureSecondaryButton(copyLogsButton, title: "Copy Logs")
@@ -163,7 +232,7 @@ private extension ViewController {
configureSecondaryButton(clearLogButton, title: "Clear Logs")
messageTextField.borderStyle = .roundedRect
- messageTextField.placeholder = "Message to selected target"
+ messageTextField.placeholder = "Typed message or ping to selected target"
messageTextField.returnKeyType = .send
messageTextField.delegate = self
@@ -175,6 +244,9 @@ private extension ViewController {
contentStack.addArrangedSubview(sectionTitle("Session"))
contentStack.addArrangedSubview(localPeerLabel)
+ contentStack.addArrangedSubview(backendControl)
+ contentStack.addArrangedSubview(connectionBehaviorControl)
+ contentStack.addArrangedSubview(backendDetailLabel)
contentStack.addArrangedSubview(statusCardRow())
contentStack.addArrangedSubview(modeButton)
contentStack.addArrangedSubview(buttonRow([startStopButton, refreshButton]))
@@ -182,6 +254,7 @@ private extension ViewController {
contentStack.addArrangedSubview(sectionTitle("Peers"))
contentStack.addArrangedSubview(discoveredPeersLabel)
+ contentStack.addArrangedSubview(inviteButtonsStack)
contentStack.addArrangedSubview(connectedPeersLabel)
contentStack.addArrangedSubview(sectionTitle("Messages"))
@@ -207,6 +280,13 @@ private extension ViewController {
}
func configureActions() {
+ backendControl.addTarget(self, action: #selector(changedBackend(_:)), for: .valueChanged)
+ connectionBehaviorControl.addTarget(self, action: #selector(changedConnectionBehavior(_:)), for: .valueChanged)
+ backendControl.accessibilityLabel = "Networking backend"
+ backendControl.accessibilityHint = "Selects the transport backend while networking is stopped"
+ connectionBehaviorControl.accessibilityLabel = "Connection behavior"
+ connectionBehaviorControl.accessibilityHint = "Choose automatic connections or explicit peer invitations while networking is stopped"
+ startStopButton.accessibilityHint = "Starts or stops peer advertising and browsing"
startStopButton.addTarget(self, action: #selector(tappedStartStop(_:)), for: .touchUpInside)
refreshButton.addTarget(self, action: #selector(tappedRefresh(_:)), for: .touchUpInside)
resetButton.addTarget(self, action: #selector(tappedReset(_:)), for: .touchUpInside)
@@ -289,11 +369,20 @@ private extension ViewController {
}
localPeerLabel.text = "Local: \(pcm.peer.displayName)"
+ connectionBehaviorControl.selectedSegmentIndex = selectedConnectionBehavior == .automatic ? 0 : 1
+ if selectedConnectionBehavior == .automatic {
+ backendDetailLabel.text = "\(backendName) · automatic discovery and connections\(isNetworkBackend ? " · unauthenticated demo transport" : " · default optional encryption and certificate policy")"
+ } else {
+ backendDetailLabel.text = "\(backendName) · app-owned discovery and explicit invitations via PeerBrowserModel\(isNetworkBackend ? " · unauthenticated demo transport" : "")"
+ }
+ backendControl.isEnabled = !isNetworking
+ connectionBehaviorControl.isEnabled = !isNetworking
statusBadgeLabel.text = isNetworking ? "Running" : "Stopped"
advertisingBadgeLabel.text = isNetworking && mode.isAdvertising ? "Advertising" : "Not Advertising"
browsingBadgeLabel.text = isNetworking && mode.isBrowsing ? "Browsing" : "Not Browsing"
connectedBadgeLabel.text = "Connected\n\(connectedPeers.count)"
discoveredPeersLabel.text = peerList(title: "Discovered", peers: discoveredPeers)
+ rebuildInviteButtons()
connectedPeersLabel.text = peerList(title: "Connected", peers: connectedPeers)
startStopButton.configuration?.title = isNetworking ? "Stop" : "Start"
refreshButton.isEnabled = isNetworking
@@ -333,6 +422,16 @@ private extension ViewController {
if !isNetworking {
hints.append("Start networking to advertise, browse, and connect to nearby devices.")
}
+ if selectedConnectionBehavior == .requireInvitation {
+ hints.append("Require Invitation waits for an explicit Invite action on a discovered peer before messages can be sent.")
+ } else {
+ hints.append("Automatic connects to discovered peers without an app-owned Invite action.")
+ }
+ if isNetworkBackend {
+ hints.append("Network demo traffic is unauthenticated; do not send sensitive data.")
+ } else {
+ hints.append("Multipeer remains the default backend.")
+ }
if isNetworking && discoveredPeers.isEmpty && connectedPeers.isEmpty {
hints.append("No peers yet. Confirm both devices use the same service type, are on the same Wi‑Fi or have Bluetooth enabled, and accepted Local Network permission.")
}
@@ -361,7 +460,7 @@ private extension ViewController {
isNetworking = true
if mode.isAdvertising { checkedItems.insert(.deviceAAdvertising) }
if mode.isBrowsing { checkedItems.insert(.deviceBBrowsing) }
- appendLog(kind: "session.start.requested", detail: mode.rawValue)
+ appendLog(kind: "session.start.requested", detail: "\(backendName), \(selectedConnectionBehavior.rawValue), \(mode.rawValue)")
refreshUI()
}
@@ -539,7 +638,9 @@ private extension ViewController {
case .lostPeer(let peer):
appendLog(kind: "peer.lost", detail: peer.displayName, peers: [peer])
case .nearbyPeersChanged(let peers):
- discoveredPeers = peers
+ if selectedConnectionBehavior == .automatic {
+ discoveredPeers = peers
+ }
if !peers.isEmpty { checkedItems.insert(.peerDiscovered) }
appendLog(kind: "peers.nearby.changed", detail: "\(peers.count) nearby", peers: peers)
case .receivedData(let peer, let data):
@@ -602,6 +703,46 @@ private extension ViewController {
refreshUI()
}
+ func rebuildInviteButtons() {
+ inviteButtonsStack.arrangedSubviews.forEach { view in
+ inviteButtonsStack.removeArrangedSubview(view)
+ view.removeFromSuperview()
+ }
+ inviteButtonsStack.isHidden = selectedConnectionBehavior != .requireInvitation
+ guard selectedConnectionBehavior == .requireInvitation else { return }
+
+ if discoveredPeers.isEmpty {
+ let label = UILabel()
+ label.text = "Start browsing to discover peers available for manual invitation."
+ label.numberOfLines = 0
+ label.font = UIFont.preferredFont(forTextStyle: .footnote)
+ label.textColor = .secondaryLabel
+ inviteButtonsStack.addArrangedSubview(label)
+ return
+ }
+
+ discoveredPeers.forEach { peer in
+ let button = UIButton(type: .system)
+ configureSecondaryButton(button, title: "Invite \(peer.displayName) — \(statusText(peer.status))")
+ button.contentHorizontalAlignment = .leading
+ button.isEnabled = isNetworking && peer.status == .notConnected
+ button.accessibilityLabel = "Invite \(peer.displayName)"
+ button.accessibilityHint = button.isEnabled
+ ? "Sends an explicit \(backendName) invitation"
+ : "This peer is not currently available for invitation"
+ button.addAction(UIAction { [weak self] _ in
+ self?.invite(peer)
+ }, for: .touchUpInside)
+ inviteButtonsStack.addArrangedSubview(button)
+ }
+ }
+
+ func invite(_ peer: Peer) {
+ guard isNetworking, selectedConnectionBehavior == .requireInvitation, peer.status == .notConnected else { return }
+ browserModel.invitePeer(peer)
+ appendLog(kind: "invitation.sent", detail: "Invited \(peer.displayName)", peers: [peer], direction: .outbound)
+ }
+
func updateTargetMenu() {
targetButton.configuration?.title = "Target: \(targetSummary()) ▾"
let broadcastState : UIMenuElement.State = selectedTargetPeer == nil ? .on : .off
@@ -686,6 +827,25 @@ private extension ViewController {
appendLog(kind: "app.ready", detail: "Local peer: \(pcm.peer.displayName)")
}
+ @objc func changedBackend(_ sender: UISegmentedControl) {
+ guard !isNetworking else { return }
+ discoveredPeers = []
+ connectedPeers = []
+ selectedTargetPeer = nil
+ configureManager()
+ appendLog(kind: "session.backend.changed", detail: "\(backendName), \(selectedConnectionBehavior.rawValue)")
+ }
+
+ @objc func changedConnectionBehavior(_ sender: UISegmentedControl) {
+ guard !isNetworking else { return }
+ selectedConnectionBehavior = sender.selectedSegmentIndex == 0 ? .automatic : .requireInvitation
+ discoveredPeers = []
+ connectedPeers = []
+ selectedTargetPeer = nil
+ configureManager()
+ appendLog(kind: "session.connection.behavior.changed", detail: "\(backendName), \(selectedConnectionBehavior.rawValue)")
+ }
+
@objc func tappedStartStop(_ sender: UIButton) {
isNetworking ? stopNetworking() : startNetworking()
}
diff --git a/PeerConnectivityTests/ObservableTests.swift b/PeerConnectivityTests/ObservableTests.swift
index cc6fa60..72622cf 100644
--- a/PeerConnectivityTests/ObservableTests.swift
+++ b/PeerConnectivityTests/ObservableTests.swift
@@ -24,6 +24,20 @@ class ObservableTests: XCTestCase {
XCTAssertEqual(received, [7])
}
+ func testObservableCanSkipCurrentValueAndReceiveFutureUpdates() async throws {
+ let observable = Observable(7)
+ var received : [Int] = []
+
+ await observable.addObserverAsync({ value in
+ received.append(value)
+ }, replayCurrentValue: false)
+ XCTAssertTrue(received.isEmpty)
+
+ await observable.updateAsync(8)
+
+ XCTAssertEqual(received, [8])
+ }
+
func testObservableNotifiesObserversWhenValueChanges() async throws {
let observable = Observable("initial")
var received : [String] = []
diff --git a/PeerConnectivityTests/ObservableThreadSafetyTests.swift b/PeerConnectivityTests/ObservableThreadSafetyTests.swift
index d5a3651..b8bae86 100644
--- a/PeerConnectivityTests/ObservableThreadSafetyTests.swift
+++ b/PeerConnectivityTests/ObservableThreadSafetyTests.swift
@@ -76,6 +76,45 @@ final class ObservableThreadSafetyTests: XCTestCase {
// MARK: - PeerConnectionResponder
+ func testResponderSynchronousListenerSkipsCurrentEventAndReceivesFutureEvent() async {
+ let observable = Observable(.ready)
+ let responder = PeerConnectionResponder(observer: observable)
+ var receivedStarted = false
+
+ responder.addListener({ event in
+ if case .started = event {
+ receivedStarted = true
+ } else {
+ XCTFail("Responder replayed the current event")
+ }
+ }, forKey: "listener")
+ await observable.flush()
+ XCTAssertFalse(receivedStarted)
+
+ await observable.updateAsync(.started)
+
+ XCTAssertTrue(receivedStarted)
+ }
+
+ func testResponderAsyncListenerSkipsCurrentEventAndReceivesFutureEvent() async {
+ let observable = Observable(.ready)
+ let responder = PeerConnectionResponder(observer: observable)
+ var receivedStarted = false
+
+ await responder.addListenerAsync({ event in
+ if case .started = event {
+ receivedStarted = true
+ } else {
+ XCTFail("Responder replayed the current event")
+ }
+ }, forKey: "listener")
+ XCTAssertFalse(receivedStarted)
+
+ await observable.updateAsync(.started)
+
+ XCTAssertTrue(receivedStarted)
+ }
+
func testResponderAllowsConcurrentListenerRemovalAndEventDelivery() {
let observable = Observable(.ready)
let responder = PeerConnectionResponder(observer: observable)
diff --git a/PeerConnectivityTests/PeerBrowserModelTests.swift b/PeerConnectivityTests/PeerBrowserModelTests.swift
new file mode 100644
index 0000000..1ee84f9
--- /dev/null
+++ b/PeerConnectivityTests/PeerBrowserModelTests.swift
@@ -0,0 +1,323 @@
+//
+// PeerBrowserModelTests.swift
+// PeerConnectivityTests
+//
+// Created by Reid Chatham on 12/23/15.
+// Copyright © 2015 Reid Chatham. All rights reserved.
+//
+
+import XCTest
+@testable import PeerConnectivity
+
+private final class BrowserModelMockSessionTransport : PeerSessionTransport {
+ internal let peer : Peer
+ internal var connectedPeers : [Peer] = []
+
+ internal init(peer: Peer) {
+ self.peer = peer
+ }
+
+ internal func startSession() {}
+
+ internal func stopSession() {}
+
+ internal func sendData(_ data: Data, toPeers peers: [Peer]) {}
+
+ internal func sendDataStream(_ streamName: String, toPeer peer: Peer) throws -> OutputStream {
+ return OutputStream.toMemory()
+ }
+
+ internal func sendResourceAtURL(_ resourceURL: URL,
+ withName name: String,
+ toPeer peer: Peer,
+ withCompletionHandler completion: ((Error?)->Void)?) -> Progress? {
+ return nil
+ }
+}
+
+private final class BrowserModelMockBrowserTransport : PeerBrowserTransport {
+ internal private(set) var invitedPeers : [Peer] = []
+
+ internal func invitePeer(_ peer: Peer, withContext context: Data?, timeout: TimeInterval) {
+ invitedPeers.append(peer)
+ }
+
+ internal func startBrowsing() {}
+
+ internal func stopBrowsing() {}
+}
+
+private struct BrowserModelNoOpAdvertiserTransport : PeerAdvertiserTransport {
+ internal func startAdvertising() {}
+
+ internal func stopAdvertising() {}
+}
+
+private struct BrowserModelNoOpAdvertiserAssisstantTransport : PeerAdvertiserAssisstantTransport {
+ internal func startAdvertisingAssisstant() {}
+
+ internal func stopAdvertisingAssisstant() {}
+}
+
+private final class BrowserModelSendableBox : @unchecked Sendable {
+ internal let model : PeerBrowserModel
+
+ internal init(_ model: PeerBrowserModel) {
+ self.model = model
+ }
+}
+
+private final class PeerBrowserModelHarness {
+ internal let browser = BrowserModelMockBrowserTransport()
+ internal var browserObserver : Observable?
+ internal var sessionObserver : Observable?
+
+ internal var factory : PeerConnectionTransportFactory {
+ return PeerConnectionTransportFactory(
+ backend: .multipeerConnectivity,
+ makeSession: { [weak self] peer, _, observer in
+ self?.sessionObserver = observer
+ return BrowserModelMockSessionTransport(peer: peer)
+ },
+ makeBrowser: { [weak self] _, _, observer in
+ self?.browserObserver = observer
+ return self!.browser
+ },
+ makeAdvertiser: { _, _, _, _ in
+ return BrowserModelNoOpAdvertiserTransport()
+ },
+ makeAdvertiserAssisstant: { _, _, _, _ in
+ return BrowserModelNoOpAdvertiserAssisstantTransport()
+ }
+ )
+ }
+}
+
+final class PeerBrowserModelTests : XCTestCase {
+
+ internal func testModelTracksFoundAndLostPeers() async {
+ let harness = PeerBrowserModelHarness()
+ let manager = makeManager(harness: harness)
+ let peer = Peer(identity: PeerIdentity(identifier: "remote", displayName: "Remote"), status: .notConnected)
+ let foundExpectation = expectation(description: "Model found peer")
+ let lostExpectation = expectation(description: "Model lost peer")
+ foundExpectation.assertForOverFulfill = false
+ lostExpectation.assertForOverFulfill = false
+ let model = PeerBrowserModel(manager: manager) { peers in
+ if peers == [peer] {
+ foundExpectation.fulfill()
+ } else if peers.isEmpty {
+ lostExpectation.fulfill()
+ }
+ }
+
+ model.startObserving()
+ await startBrowsingOnly(manager)
+ await harness.browserObserver?.updateAsync(.foundPeer(peer, discoveryInfo: nil))
+ await fulfillment(of: [foundExpectation], timeout: 1)
+ XCTAssertEqual(model.discoveredPeers, [peer])
+
+ await harness.browserObserver?.updateAsync(.lostPeer(peer))
+ await fulfillment(of: [lostExpectation], timeout: 1)
+ XCTAssertTrue(model.discoveredPeers.isEmpty)
+ }
+
+ internal func testModelUpdatesDiscoveredPeerStatusFromDevicesChanged() async {
+ let harness = PeerBrowserModelHarness()
+ let manager = makeManager(harness: harness)
+ let identity = PeerIdentity(identifier: "remote", displayName: "Remote")
+ let foundPeer = Peer(identity: identity, status: .notConnected)
+ let connectedPeer = Peer(identity: identity, status: .connected)
+ let otherPeer = Peer(identity: PeerIdentity(identifier: "other", displayName: "Other"), status: .notConnected)
+ let foundExpectation = self.expectation(description: "Model found peer")
+ let connectedExpectation = self.expectation(description: "Model updated peer status")
+ foundExpectation.assertForOverFulfill = false
+ connectedExpectation.assertForOverFulfill = false
+ let model = PeerBrowserModel(manager: manager) { peers in
+ if peers.first?.status == .connected {
+ connectedExpectation.fulfill()
+ } else if peers.first == foundPeer {
+ foundExpectation.fulfill()
+ }
+ }
+
+ model.startObserving()
+ await startBrowsingOnly(manager)
+ await harness.browserObserver?.updateAsync(.foundPeer(foundPeer, discoveryInfo: nil))
+ await fulfillment(of: [foundExpectation], timeout: 1)
+ await harness.sessionObserver?.updateAsync(.devicesChanged(peer: connectedPeer))
+
+ await fulfillment(of: [connectedExpectation], timeout: 1)
+ XCTAssertEqual(model.discoveredPeers.first?.status, .connected)
+
+ await harness.browserObserver?.updateAsync(.foundPeer(otherPeer, discoveryInfo: nil))
+ await shortAsyncDelay()
+ XCTAssertEqual(model.discoveredPeers.first?.status, .connected)
+ }
+
+ internal func testInvitePeerForwardsToManager() {
+ let harness = PeerBrowserModelHarness()
+ let manager = makeManager(harness: harness)
+ let model = PeerBrowserModel(manager: manager)
+ let peer = Peer(identity: PeerIdentity(identifier: "remote", displayName: "Remote"), status: .notConnected)
+
+ model.invitePeer(peer)
+
+ XCTAssertEqual(harness.browser.invitedPeers, [peer])
+ }
+
+ internal func testStopObservingClearsPeersNotifiesEmptyAndRemovesModelListener() async {
+ let harness = PeerBrowserModelHarness()
+ let manager = makeManager(harness: harness)
+ let listenerKey = "PeerBrowserModelTests.stopObserving"
+ let peer = Peer(identity: PeerIdentity(identifier: "remote", displayName: "Remote"), status: .notConnected)
+ let foundExpectation = expectation(description: "Model found peer")
+ let clearedExpectation = expectation(description: "Model cleared peers")
+ var foundPeer = false
+ let model = PeerBrowserModel(manager: manager, listenerKey: listenerKey) { peers in
+ if peers == [peer] && !foundPeer {
+ foundPeer = true
+ foundExpectation.fulfill()
+ } else if peers.isEmpty && foundPeer {
+ clearedExpectation.fulfill()
+ }
+ }
+
+ model.startObserving()
+ await startBrowsingOnly(manager)
+ await harness.browserObserver?.updateAsync(.foundPeer(peer, discoveryInfo: nil))
+ await fulfillment(of: [foundExpectation], timeout: 1)
+ XCTAssertEqual(model.discoveredPeers, [peer])
+
+ model.stopObserving()
+ XCTAssertTrue(model.discoveredPeers.isEmpty)
+ await model.waitForPendingObservationTransition()
+ await fulfillment(of: [clearedExpectation], timeout: 1)
+ await harness.browserObserver?.updateAsync(.foundPeer(peer, discoveryInfo: nil))
+
+ await shortAsyncDelay()
+ XCTAssertTrue(model.discoveredPeers.isEmpty)
+ let listenerCount = await manager.listenerCountAsync()
+ XCTAssertEqual(listenerCount, 0)
+ }
+
+ internal func testStopWhileRegistrationIsBlockedDoesNotLeakListenerOrApplyEvents() async {
+ let harness = PeerBrowserModelHarness()
+ let manager = makeManager(harness: harness)
+ let registrationStarted = DispatchSemaphore(value: 0)
+ let allowRegistration = DispatchSemaphore(value: 0)
+ let model = PeerBrowserModel(manager: manager,
+ listenerKey: "PeerBrowserModelTests.blockedRegistration",
+ lifecycleHooks: PeerBrowserModelLifecycleHooks(willRegisterListener: {
+ registrationStarted.signal()
+ allowRegistration.wait()
+ }))
+ let peer = Peer(identity: PeerIdentity(identifier: "remote", displayName: "Remote"), status: .notConnected)
+
+ model.startObserving()
+ XCTAssertEqual(registrationStarted.wait(timeout: .now() + 1), .success)
+ model.stopObserving()
+ allowRegistration.signal()
+ await model.waitForPendingObservationTransition()
+ await harness.browserObserver?.updateAsync(.foundPeer(peer, discoveryInfo: nil))
+
+ await shortAsyncDelay()
+ XCTAssertTrue(model.discoveredPeers.isEmpty)
+ let listenerCount = await manager.listenerCountAsync()
+ XCTAssertEqual(listenerCount, 0)
+ }
+
+ internal func testRepeatedRestartsDoNotReplayPreviouslyDiscoveredPeer() async {
+ let harness = PeerBrowserModelHarness()
+ let manager = makeManager(harness: harness)
+ let priorPeer = Peer(identity: PeerIdentity(identifier: "prior", displayName: "Prior"), status: .notConnected)
+ let futurePeer = Peer(identity: PeerIdentity(identifier: "future", displayName: "Future"), status: .notConnected)
+ let priorPeerExpectation = expectation(description: "Model found prior peer")
+ let futurePeerExpectation = expectation(description: "Model found future peer")
+ var foundPriorPeer = false
+ var foundFuturePeer = false
+ let model = PeerBrowserModel(manager: manager,
+ listenerKey: "PeerBrowserModelTests.repeatedRestarts") { peers in
+ if peers.contains(priorPeer) && !foundPriorPeer {
+ foundPriorPeer = true
+ priorPeerExpectation.fulfill()
+ }
+ if peers == [futurePeer] && !foundFuturePeer {
+ foundFuturePeer = true
+ futurePeerExpectation.fulfill()
+ }
+ }
+
+ model.startObserving()
+ await startBrowsingOnly(manager)
+ await harness.browserObserver?.updateAsync(.foundPeer(priorPeer, discoveryInfo: nil))
+ await fulfillment(of: [priorPeerExpectation], timeout: 1)
+ model.stopObserving()
+ await model.waitForPendingObservationTransition()
+ XCTAssertTrue(model.discoveredPeers.isEmpty)
+
+ for _ in 0..<3 {
+ model.startObserving()
+ await model.waitForPendingObservationTransition()
+ await shortAsyncDelay()
+ XCTAssertTrue(model.discoveredPeers.isEmpty)
+
+ model.stopObserving()
+ await model.waitForPendingObservationTransition()
+ XCTAssertTrue(model.discoveredPeers.isEmpty)
+ }
+
+ model.startObserving()
+ await model.waitForPendingObservationTransition()
+ await harness.browserObserver?.updateAsync(.lostPeer(priorPeer))
+ await harness.browserObserver?.updateAsync(.foundPeer(futurePeer, discoveryInfo: nil))
+ await fulfillment(of: [futurePeerExpectation], timeout: 1)
+ XCTAssertEqual(model.discoveredPeers, [futurePeer])
+ }
+
+ internal func testRepeatedConcurrentStartStopCyclesLeaveNoListener() async {
+ let harness = PeerBrowserModelHarness()
+ let manager = makeManager(harness: harness)
+ let model = PeerBrowserModel(manager: manager,
+ listenerKey: "PeerBrowserModelTests.concurrentCycles")
+ let modelBox = BrowserModelSendableBox(model)
+ let operationsFinished = expectation(description: "Concurrent observation operations finished")
+
+ DispatchQueue.global().async {
+ DispatchQueue.concurrentPerform(iterations: 100) { iteration in
+ if iteration.isMultiple(of: 2) {
+ modelBox.model.startObserving()
+ } else {
+ modelBox.model.stopObserving()
+ }
+ }
+ operationsFinished.fulfill()
+ }
+
+ await fulfillment(of: [operationsFinished], timeout: 2)
+ model.stopObserving()
+ await model.waitForPendingObservationTransition()
+
+ let listenerCount = await manager.listenerCountAsync()
+ XCTAssertEqual(listenerCount, 0)
+ }
+
+ private func startBrowsingOnly(_ manager: PeerConnectionManager) async {
+ await withCheckedContinuation { continuation in
+ manager.startBrowsingOnly {
+ continuation.resume()
+ }
+ }
+ }
+
+ private func shortAsyncDelay() async {
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+
+ private func makeManager(harness: PeerBrowserModelHarness) -> PeerConnectionManager {
+ return PeerConnectionManager(serviceType: "browser-model",
+ connectionType: .custom,
+ displayName: "Local",
+ transportFactory: harness.factory)
+ }
+}
diff --git a/PeerConnectivityTests/PeerConnectionManagerTransportTests.swift b/PeerConnectivityTests/PeerConnectionManagerTransportTests.swift
index 9c6c752..94ef874 100644
--- a/PeerConnectivityTests/PeerConnectionManagerTransportTests.swift
+++ b/PeerConnectivityTests/PeerConnectionManagerTransportTests.swift
@@ -283,7 +283,7 @@ final class PeerConnectionManagerTransportTests : XCTestCase {
startsCompleted.expectedFulfillmentCount = 100
var lifecycleEvents : [String] = []
- manager.listenOn({ event in
+ await manager.listenOnAsync({ event in
switch event {
case .ready: lifecycleEvents.append("ready")
case .started: lifecycleEvents.append("started")
@@ -302,7 +302,7 @@ final class PeerConnectionManagerTransportTests : XCTestCase {
await fulfillment(of: [startsCompleted], timeout: 2)
await manager.removeListenerForKeyAsync("lifecycle")
- let expected = ["ready"] + Array(repeating: ["ended", "ready"], count: 100).flatMap { $0 }
+ let expected = Array(repeating: ["ended", "ready"], count: 100).flatMap { $0 }
XCTAssertEqual(lifecycleEvents, expected)
}
diff --git a/PeerConnectivityTests/PeerConnectivityTests.swift b/PeerConnectivityTests/PeerConnectivityTests.swift
index a4dee69..59d580e 100644
--- a/PeerConnectivityTests/PeerConnectivityTests.swift
+++ b/PeerConnectivityTests/PeerConnectivityTests.swift
@@ -31,72 +31,62 @@ class PeerConnectivityTests: XCTestCase {
assertStatus(manager.peer.status, is: .currentUser)
}
- func testListenOnImmediatelyReceivesReadyEventInBackgroundMode() async throws {
+ func testListenOnDoesNotReplayReadyEventInBackgroundMode() async throws {
let manager = PeerConnectionManager(serviceType: "test-listen", displayName: "Listener")
- pcm = manager
- let expectation = expectation(description: "Ready event received")
- expectation.assertForOverFulfill = false
+ let expectation = expectation(description: "Ready event is not replayed")
+ expectation.isInverted = true
manager.listenOn({ event in
- switch event {
- case .ready:
+ if case .ready = event {
expectation.fulfill()
- default:
- break
}
}, performListenerInBackground: true, withKey: "ready")
- await fulfillment(of: [expectation], timeout: 1)
+ await fulfillment(of: [expectation], timeout: 0.1)
+ await manager.removeListenerForKeyAsync("ready")
+ manager.stop()
}
func testRemovedListenerDoesNotReceiveLaterEvents() async throws {
let manager = PeerConnectionManager(serviceType: "test-remove", displayName: "Listener")
pcm = manager
- let readyExpectation = expectation(description: "Ready event received")
- readyExpectation.assertForOverFulfill = false
let removedExpectation = expectation(description: "Removed listener receives no later events")
removedExpectation.isInverted = true
var eventCount = 0
var didRemoveListener = false
- manager.listenOn({ event in
+ await manager.listenOnAsync({ _ in
eventCount += 1
- if case .ready = event { readyExpectation.fulfill() }
if didRemoveListener { removedExpectation.fulfill() }
}, performListenerInBackground: true, withKey: "removed")
- await fulfillment(of: [readyExpectation], timeout: 1)
didRemoveListener = true
await manager.removeListenerForKeyAsync("removed")
manager.stop()
await fulfillment(of: [removedExpectation], timeout: 0.1)
- XCTAssertEqual(eventCount, 1)
+ XCTAssertEqual(eventCount, 0)
}
func testRemoveAllListenersRemovesRegisteredListeners() async throws {
let manager = PeerConnectionManager(serviceType: "test-all", displayName: "Listener")
pcm = manager
- let readyExpectation = expectation(description: "Ready event received")
- readyExpectation.assertForOverFulfill = false
let removedExpectation = expectation(description: "Removed listeners receive no later events")
removedExpectation.isInverted = true
var eventCount = 0
var didRemoveListeners = false
- manager.listenOn({ event in
+ await manager.listenOnAsync({ _ in
eventCount += 1
- if case .ready = event { readyExpectation.fulfill() }
if didRemoveListeners { removedExpectation.fulfill() }
}, performListenerInBackground: true, withKey: "removed")
- await fulfillment(of: [readyExpectation], timeout: 1)
didRemoveListeners = true
await manager.removeAllListenersAsync()
manager.stop()
await fulfillment(of: [removedExpectation], timeout: 0.1)
- XCTAssertEqual(eventCount, 1)
+ XCTAssertEqual(eventCount, 0)
}
private func assertStatus(_ status: Peer.Status, is expected: Peer.Status, file: StaticString = #file, line: UInt = #line) {
diff --git a/PeerConnectivityTests/PeerSecurityConfigurationTests.swift b/PeerConnectivityTests/PeerSecurityConfigurationTests.swift
index e78edb9..babdb58 100644
--- a/PeerConnectivityTests/PeerSecurityConfigurationTests.swift
+++ b/PeerConnectivityTests/PeerSecurityConfigurationTests.swift
@@ -144,15 +144,11 @@ class PeerSecurityConfigurationTests: XCTestCase {
var receivedCertificate: [Any]?
let expectedCertificate: [Any] = ["certificate"]
- let readyExpectation = expectation(description: "Ready event received")
- readyExpectation.assertForOverFulfill = false
let certificateExpectation = expectation(description: "Certificate event received")
certificateExpectation.assertForOverFulfill = false
- manager.listenOn({ event in
+ await manager.listenOnAsync({ event in
switch event {
- case .ready:
- readyExpectation.fulfill()
case .receivedCertificate(let peer, let certificate, let handler):
receivedPeer = peer
receivedCertificate = certificate
@@ -161,7 +157,6 @@ class PeerSecurityConfigurationTests: XCTestCase {
default: break
}
}, performListenerInBackground: true, withKey: "certificate-compatibility")
- await fulfillment(of: [readyExpectation], timeout: 1)
manager.handleCertificate(peer: manager.peer, certificate: expectedCertificate) { accepted in
result = accepted
@@ -198,15 +193,11 @@ class PeerSecurityConfigurationTests: XCTestCase {
var receivedPeer: Peer?
let expectedContext = "automatic".data(using: .utf8)
- let readyExpectation = expectation(description: "Ready event received")
- readyExpectation.assertForOverFulfill = false
let invitationExpectation = expectation(description: "Invitation event received")
invitationExpectation.assertForOverFulfill = false
- manager.listenOn({ event in
+ await manager.listenOnAsync({ event in
switch event {
- case .ready:
- readyExpectation.fulfill()
case .receivedInvitation(let peer, let context, let invitationHandler):
receivedPeer = peer
XCTAssertEqual(context, expectedContext)
@@ -215,7 +206,6 @@ class PeerSecurityConfigurationTests: XCTestCase {
default: break
}
}, performListenerInBackground: true, withKey: "automatic-invitation-compatibility")
- await fulfillment(of: [readyExpectation], timeout: 1)
manager.handleInvitation(peer: manager.peer, context: expectedContext) { accepted, _ in
result = accepted
@@ -251,15 +241,11 @@ class PeerSecurityConfigurationTests: XCTestCase {
var receivedContext: Data?
let expectedContext = "manual".data(using: .utf8)
- let readyExpectation = expectation(description: "Ready event received")
- readyExpectation.assertForOverFulfill = false
let invitationExpectation = expectation(description: "Invitation event received")
invitationExpectation.assertForOverFulfill = false
- manager.listenOn({ event in
+ await manager.listenOnAsync({ event in
switch event {
- case .ready:
- readyExpectation.fulfill()
case .receivedInvitation(let peer, let context, let invitationHandler):
receivedPeer = peer
receivedContext = context
@@ -268,7 +254,6 @@ class PeerSecurityConfigurationTests: XCTestCase {
default: break
}
}, performListenerInBackground: true, withKey: "manual-invitation")
- await fulfillment(of: [readyExpectation], timeout: 1)
var result: Bool?
manager.handleInvitation(peer: manager.peer, context: expectedContext) { accepted, _ in
@@ -286,15 +271,11 @@ class PeerSecurityConfigurationTests: XCTestCase {
let manager = makeManager(invitationPolicy: .acceptAll, connectionType: .custom)
var receivedInvitation = false
- let readyExpectation = expectation(description: "Ready event received")
- readyExpectation.assertForOverFulfill = false
let invitationExpectation = expectation(description: "Invitation event received")
invitationExpectation.assertForOverFulfill = false
- manager.listenOn({ event in
+ await manager.listenOnAsync({ event in
switch event {
- case .ready:
- readyExpectation.fulfill()
case .receivedInvitation(_, _, let invitationHandler):
receivedInvitation = true
invitationHandler(false)
@@ -302,7 +283,6 @@ class PeerSecurityConfigurationTests: XCTestCase {
default: break
}
}, performListenerInBackground: true, withKey: "custom-invitation")
- await fulfillment(of: [readyExpectation], timeout: 1)
var result: Bool?
manager.handleInvitation(peer: manager.peer, context: nil) { accepted, _ in
diff --git a/PeerPlayground.playground/Contents.swift b/PeerPlayground.playground/Contents.swift
index 4c1b55a..81e642a 100644
--- a/PeerPlayground.playground/Contents.swift
+++ b/PeerPlayground.playground/Contents.swift
@@ -175,6 +175,8 @@ if let somePeerThatIAmConnectedTo = connectedPeers.first {
// Events can be sent to specific peers
pcm.sendEvent(event, toPeers: [somePeerThatIAmConnectedTo])
+ // Stream and resource APIs require the MultipeerConnectivity backend. The Network
+ // backend supports bounded payloads through sendData or sendMessage instead.
do {
let stream = try pcm.sendDataStream(streamName: "some-stream", toPeer: somePeerThatIAmConnectedTo)
// Do something with stream
@@ -287,7 +289,7 @@ pcm.listenOn({ (event) in
}, withKey: "connectedDevicesChanged")
-// Listen to streams
+// Listen to streams (MultipeerConnectivity backend only; Network never emits this event)
pcm.listenOn({ event in
switch event {
@@ -300,7 +302,7 @@ pcm.listenOn({ event in
}, withKey: "streamListener")
-// Receiving resources
+// Receiving resources (MultipeerConnectivity backend only; Network never emits these events)
pcm.listenOn({ event in
switch event {
diff --git a/README.md b/README.md
index 75c6151..dca102d 100644
--- a/README.md
+++ b/README.md
@@ -40,16 +40,16 @@ Add the UI helper product only when you need UIKit browser view controller suppo
CocoaPods and Carthage are no longer the recommended distribution paths for new releases.
-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).
+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), the stronger identity roadmap in [NetworkTrustModelPlan.md](NetworkTrustModelPlan.md), and the authoritative stable/default/removal gates in [NetworkMigrationReadinessAudit.md](NetworkMigrationReadinessAudit.md).
## Experimental Network framework backend
`PeerConnectionManager` can be explicitly initialized with `backend: .networkFramework` on supported OS versions. The default backend remains `.multipeerConnectivity`.
-Use `.preSharedKey` with high-entropy app-managed key material for authenticated encrypted Network sessions:
+Use `.preSharedKey` with at least 32 CSPRNG-generated bytes of app-managed key material for encrypted Network sessions authenticated as members of the same key-sharing group:
```swift
-let secret = Data("replace-with-an-app-managed-secret".utf8)
+let secret : Data = loadProvisionedNetworkPSK() // At least 32 random bytes; app-defined provisioning.
let pcm = PeerConnectionManager(serviceType: "local",
backend: .networkFramework,
networkSecurity: .preSharedKey(secret))
@@ -57,7 +57,31 @@ let pcm = PeerConnectionManager(serviceType: "local",
The default `networkSecurity: .unauthenticated` mode is plaintext TCP, remains available only for source compatibility and diagnostics, and must not be used for sensitive data.
-See [NetworkBackendGuide.md](NetworkBackendGuide.md) for the full migration guide, security model, support matrix, demo launch arguments, validation coverage, and known limitations.
+Network adopters must add local-network privacy metadata to the **app target**, not the package or framework plist. For the service type above, the minimum declarations are:
+
+```xml
+NSLocalNetworkUsageDescription
+Discover and connect to nearby devices running this app.
+NSBonjourServices
+
+ _local._tcp
+
+```
+
+The backend maps the bare `serviceType: "local"` to the TCP Bonjour type `_local._tcp`; declare every service type the app uses. It does not advertise `_local._udp`. Physical-device testing is required to validate the Local Network permission flow and the app's supported Wi-Fi/peer-to-peer topologies; simulator success is not sufficient for production readiness.
+
+See [NetworkBackendGuide.md](NetworkBackendGuide.md) for the complete production setup, service-type mapping, physical-device checklist, support matrix, demo launch arguments, and known limitations. See [NetworkTrustModelPlan.md](NetworkTrustModelPlan.md) for the current group-membership boundary, display-name spoofing threat model, and future individual-identity options.
+
+### Stream and resource APIs
+
+The current Network backend supports reliable `Data` and `PeerMessage` exchange, but it does not implement a custom stream or resource-transfer protocol. The following APIs remain available and supported only when the manager uses `.multipeerConnectivity`:
+
+- `sendDataStream(streamName:toPeer:)`
+- `sendResourceAtURL(_:withName:toPeers:withCompletionHandler:)`
+- `.receivedStream`
+- `.startedReceivingResource` and `.finishedReceivingResource`
+
+With `.networkFramework`, stream sends throw an unsupported-operation error, resource sends return `nil` progress and report an unsupported-operation error, and the corresponding receive events are never emitted. Use `sendData` or `sendMessage` for bounded payloads, or retain the default MultipeerConnectivity backend when stream/resource behavior is required. See [the compatibility decision](NetworkBackendGuide.md#stream-and-resource-compatibility-decision) for rationale and exact behavior.
## Creating/Stopping/Starting
@@ -116,12 +140,33 @@ let filteredBrowserViewController = pcm.browserViewController({ _ in }, peerFilt
})
```
+The Network backend does not have an `MCBrowserViewController` equivalent. During this migration phase, `PeerBrowserModel` is the supported foundation for app-owned UIKit or SwiftUI peer-selection UI:
+
+```swift
+let browserModel = PeerBrowserModel(manager: pcm) { peers in
+ // Update app-owned UI; this callback runs on the main queue.
+}
+
+browserModel.startObserving()
+
+if let approvedPeer = browserModel.discoveredPeers.first {
+ browserModel.invitePeer(approvedPeer)
+}
+```
+
+A reusable Network browser view is intentionally deferred until app-owned integrations establish common UI requirements. See [NetworkBackendGuide.md](NetworkBackendGuide.md#browser-ui-decision) for the decision and lifecycle guidance.
+
## Demo App
-Run `PeerConnectivityDemo.xcodeproj` on two simulators or devices and tap **Start** on both
-to exercise advertising, browsing, connection state, typed messages, raw data, resources,
-and event logging. Discovery metadata events are logged as `peer.found.metadata` when nearby
-peers advertise Bonjour TXT record values.
+Run `PeerConnectivityDemo.xcodeproj` on two simulators or devices. Before starting, select the
+backend (**Multipeer** or **Network**) and connection behavior (**Automatic** or **Require
+Invitation**). Automatic uses the selected backend's `.automatic` behavior. Require Invitation
+uses `.custom`, `PeerBrowserModel`, and visible app-owned invite actions for either backend. The
+demo remembers each backend's selection while running and preserves the existing defaults:
+Multipeer automatic, Network require invitation. All combinations retain advertising and browsing
+controls, connection state, typed message history, raw data and resource exercises, structured
+event logging, troubleshooting guidance, and the physical-test checklist. Discovery metadata
+events are logged as `peer.found.metadata` when nearby peers advertise Bonjour TXT record values.
## API Compatibility Notes
diff --git a/Sources/Observable.swift b/Sources/Observable.swift
index fd9a218..9efdcde 100644
--- a/Sources/Observable.swift
+++ b/Sources/Observable.swift
@@ -12,7 +12,7 @@ internal actor Observable {
internal typealias Observer = (T) -> Void
fileprivate enum Operation {
- case addObserver(Observer, key: String, completion: CheckedContinuation?)
+ case addObserver(Observer, key: String, replayCurrentValue: Bool, completion: CheckedContinuation?)
case removeObserver(key: String, completion: CheckedContinuation?)
case removeAllObservers(completion: CheckedContinuation?)
case update(T, completion: CheckedContinuation?)
@@ -59,14 +59,26 @@ internal actor Observable {
/// Synchronous submissions enter the actor's operation stream so observer
/// lifecycle changes and updates are applied in call order without blocking.
@discardableResult
- nonisolated internal func addObserver(_ observer: @escaping Observer) -> String {
+ nonisolated internal func addObserver(
+ _ observer: @escaping Observer,
+ replayCurrentValue: Bool = true
+ ) -> String {
let key = UUID().uuidString
- addObserver(observer, key: key)
+ addObserver(observer, key: key, replayCurrentValue: replayCurrentValue)
return key
}
- nonisolated internal func addObserver(_ observer: @escaping Observer, key: String) {
- operationContinuation.yield(.addObserver(observer, key: key, completion: nil))
+ nonisolated internal func addObserver(
+ _ observer: @escaping Observer,
+ key: String,
+ replayCurrentValue: Bool = true
+ ) {
+ operationContinuation.yield(.addObserver(
+ observer,
+ key: key,
+ replayCurrentValue: replayCurrentValue,
+ completion: nil
+ ))
}
nonisolated internal func removeObserver(forKey key: String) {
@@ -82,15 +94,27 @@ internal actor Observable {
}
@discardableResult
- nonisolated internal func addObserverAsync(_ observer: @escaping Observer) async -> String {
+ nonisolated internal func addObserverAsync(
+ _ observer: @escaping Observer,
+ replayCurrentValue: Bool = true
+ ) async -> String {
let key = UUID().uuidString
- await addObserverAsync(observer, key: key)
+ await addObserverAsync(observer, key: key, replayCurrentValue: replayCurrentValue)
return key
}
- nonisolated internal func addObserverAsync(_ observer: @escaping Observer, key: String) async {
+ nonisolated internal func addObserverAsync(
+ _ observer: @escaping Observer,
+ key: String,
+ replayCurrentValue: Bool = true
+ ) async {
await enqueueAndWait { completion in
- .addObserver(observer, key: key, completion: completion)
+ .addObserver(
+ observer,
+ key: key,
+ replayCurrentValue: replayCurrentValue,
+ completion: completion
+ )
}
}
@@ -135,8 +159,8 @@ internal actor Observable {
fileprivate func perform(_ operation: Operation) {
switch operation {
- case let .addObserver(observer, key, completion):
- storeObserver(observer, key: key)
+ case let .addObserver(observer, key, replayCurrentValue, completion):
+ storeObserver(observer, key: key, replayCurrentValue: replayCurrentValue)
completion?.resume()
case let .removeObserver(key, completion):
removeStoredObserver(forKey: key)
@@ -152,9 +176,15 @@ internal actor Observable {
}
}
- fileprivate func storeObserver(_ observer: @escaping Observer, key: String) {
+ fileprivate func storeObserver(
+ _ observer: @escaping Observer,
+ key: String,
+ replayCurrentValue: Bool
+ ) {
observers[key] = observer
- observer(value)
+ if replayCurrentValue {
+ observer(value)
+ }
}
fileprivate func removeStoredObserver(forKey key: String) {
diff --git a/Sources/PeerBrowserModel.swift b/Sources/PeerBrowserModel.swift
new file mode 100644
index 0000000..9a9f7dc
--- /dev/null
+++ b/Sources/PeerBrowserModel.swift
@@ -0,0 +1,247 @@
+//
+// PeerBrowserModel.swift
+// PeerConnectivity
+//
+// Created by Reid Chatham on 12/23/15.
+// Copyright © 2015 Reid Chatham. All rights reserved.
+//
+
+import Foundation
+
+/**
+ UIKit-neutral model for app-owned peer selection UI.
+
+ `PeerBrowserModel` observes `.foundPeer`, `.lostPeer`, `.nearbyPeersChanged`, and
+ `.devicesChanged` events from a `PeerConnectionManager`, keeps a current discovered
+ peer list, and forwards approved selections to `invitePeer`.
+
+ This is intended for `.networkFramework` apps because MultipeerConnectivity's built-in
+ browser view controller is not available for Network-backed managers. It can also be
+ used with the MultipeerConnectivity backend when an app wants custom peer UI.
+ */
+internal struct PeerBrowserModelLifecycleHooks {
+ internal let willRegisterListener : ()->Void
+ internal let willRemoveListener : ()->Void
+
+ internal init(willRegisterListener: @escaping ()->Void = {},
+ willRemoveListener: @escaping ()->Void = {}) {
+ self.willRegisterListener = willRegisterListener
+ self.willRemoveListener = willRemoveListener
+ }
+}
+
+public final class PeerBrowserModel {
+
+ /**
+ Called after the discovered peer list changes.
+ */
+ public typealias PeersChangedHandler = ([Peer])->Void
+
+ fileprivate let manager : PeerConnectionManager
+ fileprivate let listenerKey : String
+ fileprivate let lock = NSLock()
+ fileprivate var storedDiscoveredPeers : [Peer] = []
+ fileprivate var peersChangedHandler : PeersChangedHandler?
+ fileprivate let lifecycleHooks : PeerBrowserModelLifecycleHooks
+ fileprivate var observationRequested = false
+ fileprivate var isObserving = false
+ fileprivate var observationGeneration = 0
+ fileprivate var observationTransition : Task?
+
+ /**
+ Current discovered peers in display order.
+ */
+ public var discoveredPeers : [Peer] {
+ lock.lock()
+ defer { lock.unlock() }
+ return storedDiscoveredPeers
+ }
+
+ /**
+ Creates a browser model for a connection manager.
+
+ - parameter manager: Connection manager to observe and use for invitations.
+ - parameter listenerKey: Listener key used when registering with the manager. When omitted,
+ a unique key is generated so multiple models can observe the same manager.
+ - parameter peersChanged: Optional callback invoked on the main queue whenever
+ `discoveredPeers` changes.
+ */
+ public convenience init(manager: PeerConnectionManager,
+ listenerKey: String? = nil,
+ peersChanged: PeersChangedHandler? = nil) {
+ self.init(manager: manager,
+ listenerKey: listenerKey,
+ peersChanged: peersChanged,
+ lifecycleHooks: PeerBrowserModelLifecycleHooks())
+ }
+
+ internal init(manager: PeerConnectionManager,
+ listenerKey: String? = nil,
+ peersChanged: PeersChangedHandler? = nil,
+ lifecycleHooks: PeerBrowserModelLifecycleHooks) {
+ self.manager = manager
+ self.listenerKey = listenerKey ?? "PeerConnectivity.PeerBrowserModel.\(UUID().uuidString)"
+ self.peersChangedHandler = peersChanged
+ self.lifecycleHooks = lifecycleHooks
+ }
+
+ deinit {
+ stopObserving()
+ }
+
+ /**
+ Starts observing manager discovery and connection events.
+ */
+ public func startObserving() {
+ lock.lock()
+ guard !observationRequested else {
+ lock.unlock()
+ return
+ }
+ observationRequested = true
+ observationGeneration += 1
+ let previousTransition = observationTransition
+ let manager = self.manager
+ let listenerKey = self.listenerKey
+ let lifecycleHooks = self.lifecycleHooks
+ observationTransition = Task { [weak self] in
+ await previousTransition?.value
+ lifecycleHooks.willRegisterListener()
+ self?.setListenerRegistered(true)
+ await manager.listenOnAsync({ [weak self] event in
+ self?.handle(event)
+ }, performListenerInBackground: true, withKey: listenerKey)
+ }
+ lock.unlock()
+ }
+
+ /**
+ Stops observing manager events.
+ */
+ public func stopObserving() {
+ lock.lock()
+ guard observationRequested else {
+ lock.unlock()
+ return
+ }
+ observationRequested = false
+ observationGeneration += 1
+ storedDiscoveredPeers.removeAll()
+ let handler = peersChangedHandler
+ let generation = observationGeneration
+ notifyStopped(handler, generation: generation)
+ let previousTransition = observationTransition
+ let manager = self.manager
+ let listenerKey = self.listenerKey
+ let lifecycleHooks = self.lifecycleHooks
+ observationTransition = Task { [weak self] in
+ await previousTransition?.value
+ lifecycleHooks.willRemoveListener()
+ await manager.removeListenerForKeyAsync(listenerKey)
+ self?.setListenerRegistered(false)
+ }
+ lock.unlock()
+ }
+
+ internal func waitForPendingObservationTransition() async {
+ await pendingObservationTransition()?.value
+ }
+
+ fileprivate func pendingObservationTransition() -> Task? {
+ lock.lock()
+ defer { lock.unlock() }
+ return observationTransition
+ }
+
+ fileprivate func setListenerRegistered(_ registered: Bool) {
+ lock.lock()
+ isObserving = registered
+ lock.unlock()
+ }
+
+ /**
+ Invites a discovered peer after app/user approval.
+
+ With `.networkFramework`, `context` and `timeout` are currently ignored by the
+ underlying manager.
+ */
+ public func invitePeer(_ peer: Peer, withContext context: Data? = nil, timeout: TimeInterval = 30) {
+ manager.invitePeer(peer, withContext: context, timeout: timeout)
+ }
+
+ fileprivate func handle(_ event: PeerConnectionEvent) {
+ switch event {
+ case .foundPeer(let peer):
+ updatePeers { peers in
+ guard !peers.contains(peer) else { return }
+ peers.append(peer)
+ }
+ case .lostPeer(let peer):
+ updatePeers { peers in
+ peers.removeAll { $0 == peer }
+ }
+ case .nearbyPeersChanged(let foundPeers):
+ mergePeers(foundPeers)
+ case .devicesChanged(let peer, _):
+ updatePeers { peers in
+ guard let index = peers.firstIndex(of: peer) else { return }
+ peers[index] = peer
+ }
+ default:
+ break
+ }
+ }
+
+ fileprivate func mergePeers(_ peers: [Peer]) {
+ lock.lock()
+ guard observationRequested && isObserving else {
+ lock.unlock()
+ return
+ }
+ storedDiscoveredPeers = peers.map { peer in
+ return storedDiscoveredPeers.first(where: { $0 == peer }) ?? peer
+ }
+ let handler = peersChangedHandler
+ let currentPeers = storedDiscoveredPeers
+ let generation = observationGeneration
+ notify(handler, peers: currentPeers, generation: generation)
+ lock.unlock()
+ }
+
+ fileprivate func updatePeers(_ update: (inout [Peer])->Void) {
+ lock.lock()
+ guard observationRequested && isObserving else {
+ lock.unlock()
+ return
+ }
+ update(&storedDiscoveredPeers)
+ let handler = peersChangedHandler
+ let currentPeers = storedDiscoveredPeers
+ let generation = observationGeneration
+ notify(handler, peers: currentPeers, generation: generation)
+ lock.unlock()
+ }
+
+ fileprivate func notify(_ handler: PeersChangedHandler?, peers: [Peer], generation: Int) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ self.lock.lock()
+ let shouldNotify = self.observationRequested && self.isObserving &&
+ self.observationGeneration == generation
+ self.lock.unlock()
+ guard shouldNotify else { return }
+ handler?(peers)
+ }
+ }
+
+ fileprivate func notifyStopped(_ handler: PeersChangedHandler?, generation: Int) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ self.lock.lock()
+ let shouldNotify = !self.observationRequested && self.observationGeneration == generation
+ self.lock.unlock()
+ guard shouldNotify else { return }
+ handler?([])
+ }
+ }
+}
diff --git a/Sources/PeerConnectionManager.swift b/Sources/PeerConnectionManager.swift
index 79de68e..38caac6 100644
--- a/Sources/PeerConnectionManager.swift
+++ b/Sources/PeerConnectionManager.swift
@@ -83,8 +83,9 @@ extension PeerMessage {
The default backend is `.multipeerConnectivity`, preserving existing runtime behavior.
The `.networkFramework` backend is an opt-in migration path. It supports Bonjour
discovery, automatic/custom peer connection, reliable `Data`, and `PeerMessage`
- exchange. It does not yet support MultipeerConnectivity browser UI, data streams,
- resource transfer, or app-provided discovery metadata. Use
+ exchange. MultipeerConnectivity browser UI, data streams, resource transfer, and
+ their receive events remain MultipeerConnectivity-only in the current migration;
+ app-provided discovery metadata is not supported. Use
`networkSecurity: .preSharedKey(_:)` with `.networkFramework` to require an
authenticated encrypted connection.
*/
@@ -199,8 +200,8 @@ public class PeerConnectionManager {
The backend implementation used by this connection manager.
The default value is `.multipeerConnectivity`. The `.networkFramework` backend is
- opt-in and does not yet provide browser UI, stream, resource transfer, or app-provided
- discovery metadata parity.
+ opt-in; browser UI, stream/resource sends, stream/resource receive events, and
+ app-provided discovery metadata remain MultipeerConnectivity-only in the current migration.
*/
public let backend : PeerConnectionBackend
@@ -640,8 +641,9 @@ extension PeerConnectionManager {
/**
Send a data stream to a connected user. This method throws an error if the stream cannot be established. This method returns the NSOutputStream with which you can send events to the connected users.
- The Network framework backend does not support data streams yet and throws a
- `PeerConnectivity.NetworkPeerSessionTransport` error.
+ This API is MultipeerConnectivity-only in the current migration. The Network
+ framework backend throws a `PeerConnectivity.NetworkPeerSessionTransport`
+ unsupported-operation error.
- parameter streamName: The name of the stream to be established between two users.
- parameter toPeer: The peer with which to start a data stream
@@ -658,9 +660,10 @@ extension PeerConnectionManager {
/**
Send a resource with a specified url for retrieval on a connected device. This method can send a resource to multiple peers and returns an Progress associated with each Peer. This method takes an error completion handler if the resource fails to send.
- The Network framework backend does not support resource transfer yet. It returns
- `nil` progress for each requested peer and calls the completion handler with a
- `PeerConnectivity.NetworkPeerSessionTransport` error.
+ This API is MultipeerConnectivity-only in the current migration. The Network
+ framework backend returns `nil` progress for each requested peer and calls the
+ completion handler with a `PeerConnectivity.NetworkPeerSessionTransport`
+ unsupported-operation error.
- parameter resourceURL: The url that the resource will be passed with for retrieval.
- parameter withName: The name with which the progress is associated with.
@@ -986,6 +989,23 @@ extension PeerConnectionManager {
}
}
+ internal func listenOnAsync(_ listener: @escaping PeerConnectionEventListener,
+ performListenerInBackground background: Bool,
+ withKey key: String) async {
+ switch background {
+ case true:
+ await responder.addListenerAsync(listener, forKey: key)
+ case false:
+ await responder.addListenerAsync({ event in
+ let listenerTransfer = SendableTransfer(listener)
+ let eventTransfer = SendableTransfer(event)
+ DispatchQueue.main.async {
+ listenerTransfer.value(eventTransfer.value)
+ }
+ }, forKey: key)
+ }
+ }
+
/**
Takes a key to register the callback and calls the listener when an event is recieved and also passes back the `Peer` that sent it.
@@ -1056,6 +1076,10 @@ extension PeerConnectionManager {
internal func removeListenerForKeyAsync(_ key: String) async {
await responder.removeListenerForKeyAsync(key)
}
+
+ internal func listenerCountAsync() async -> Int {
+ return await responder.listenerCountAsync()
+ }
/**
Remove all listeners.
diff --git a/Sources/PeerConnectionResponder.swift b/Sources/PeerConnectionResponder.swift
index 3d8aada..b447be3 100644
--- a/Sources/PeerConnectionResponder.swift
+++ b/Sources/PeerConnectionResponder.swift
@@ -47,14 +47,20 @@ public enum PeerConnectionEvent {
case receivedMessage(peer: Peer, messageType: String, data: Data)
/**
Data stream received from `Peer`.
+
+ This event is MultipeerConnectivity-only and is not emitted by the current Network backend.
*/
case receivedStream(peer: Peer, stream: Stream, name: String)
/**
Started receiving a resource from `Peer` with name and `NSProgress`.
+
+ This event is MultipeerConnectivity-only and is not emitted by the current Network backend.
*/
case startedReceivingResource(peer: Peer, name: String, progress: Progress)
/**
Finished receiving resource from `Peer` with name at url with optional error.
+
+ This event is MultipeerConnectivity-only and is not emitted by the current Network backend.
*/
case finishedReceivingResource(peer: Peer, name: String, url: URL?, error: Error?)
/**
@@ -135,10 +141,15 @@ internal class PeerConnectionResponder {
@discardableResult internal func addListener(_ listener: @escaping PeerConnectionEventListener, forKey key: String) -> PeerConnectionResponder {
storeListener(listener, forKey: key)
- peerEventObserver.addObserver(listener, key: key)
+ peerEventObserver.addObserver(listener, key: key, replayCurrentValue: false)
return self
}
+ internal func addListenerAsync(_ listener: @escaping PeerConnectionEventListener, forKey key: String) async {
+ storeListener(listener, forKey: key)
+ await peerEventObserver.addObserverAsync(listener, key: key, replayCurrentValue: false)
+ }
+
@discardableResult internal func addListeners(_ listeners: [String:PeerConnectionEventListener]) -> PeerConnectionResponder {
listeners.forEach { addListener($0.1, forKey: $0.0) }
return self
@@ -164,6 +175,10 @@ internal class PeerConnectionResponder {
await peerEventObserver.removeObserverAsync(forKey: key)
}
+ internal func listenerCountAsync() async -> Int {
+ return await peerEventObserver.observerCount
+ }
+
fileprivate func storeListener(_ listener: @escaping PeerConnectionEventListener, forKey key: String) {
listenersLock.lock()
storedListeners[key] = listener
diff --git a/docs/images/better-demo-app-home.png b/docs/images/better-demo-app-home.png
index 7054076..1f08c88 100644
Binary files a/docs/images/better-demo-app-home.png and b/docs/images/better-demo-app-home.png differ