AgentSkillsCN

multipeerconnectivity-p2p-streaming

使用苹果的MultipeerConnectivity框架进行点对点视频流。当您构建本地mesh网络应用、设备间视频流(婴儿监视器、安全摄像头)、无需服务器的本地多人游戏,或类似AirDrop的自定义协议文件共享时,可使用此技能。

SKILL.md
--- frontmatter
name: multipeerconnectivity-p2p-streaming
version: 0.1.0
author: cmtzco
description: Peer-to-peer video streaming using Apple's MultipeerConnectivity framework. Use this skill when building local mesh networking apps, video streaming between devices (baby monitors, security cameras), local multiplayer games without servers, or AirDrop-like file sharing with custom protocols.

Skill: MultipeerConnectivity P2P Streaming

Peer-to-peer video streaming using Apple's MultipeerConnectivity framework with per-viewer session architecture, trust-based authentication, and reliable video frame delivery.

When to Use

  • Local mesh networking: Connecting 2-8 devices without internet/server infrastructure
  • Video streaming between devices: Baby monitors, security cameras, remote displays
  • Game sessions: Local multiplayer without server requirements
  • File/data sharing: AirDrop-like functionality with custom protocols
  • Proximity-based features: Nearby device discovery and communication

Consider Network.framework instead when:

  • You have a clear client/server model (not true peer-to-peer)
  • You need lower latency for real-time applications (per TN3151)
  • You require fine-grained control over transport protocols

Key Concepts

Core Components

ComponentRoleDescription
MCPeerIDIdentityUnique device identifier (MUST be persisted for reconnection)
MCSessionConnectionManages peer connections and data transfer
MCNearbyServiceAdvertiserDiscoveryAdvertises availability to nearby peers
MCNearbyServiceBrowserDiscoverySearches for advertising peers

Bonjour Service Type Naming (RFC 6763)

code
Format: _<service>._<protocol>
Rules:
- Maximum 15 characters (excluding underscores)
- Lowercase letters, numbers, hyphens only
- Must not start or end with hyphen

Examples:
✅ "carseet-stream"   (valid: 14 chars)
✅ "myapp-share"      (valid: 11 chars)  
❌ "my-really-long-service-name" (invalid: > 15 chars)

Per-Viewer Session Architecture

The recommended pattern uses separate MCSession per connected peer rather than a single shared session:

code
┌─────────────────────────────────────────────────────┐
│                   STREAMER (Camera)                  │
│                                                      │
│  ┌──────────────┐  ┌──────────────┐  ┌────────────┐ │
│  │ MCSession A  │  │ MCSession B  │  │ MCSession C│ │
│  │ (Viewer 1)   │  │ (Viewer 2)   │  │ (Viewer 3) │ │
│  └──────┬───────┘  └──────┬───────┘  └──────┬─────┘ │
└─────────┼─────────────────┼─────────────────┼───────┘
          │                 │                 │
          ▼                 ▼                 ▼
     ┌─────────┐       ┌─────────┐       ┌─────────┐
     │ VIEWER 1│       │ VIEWER 2│       │ VIEWER 3│
     └─────────┘       └─────────┘       └─────────┘

Benefits:

  • Selective disconnection: Revoke one viewer without affecting others
  • Per-viewer state tracking: Independent connection status per device
  • Granular access control: Different trust levels per connection

Encryption Options

swift
MCSession(
    peer: peerID,
    securityIdentity: nil,
    encryptionPreference: .required // .none, .optional, .required
)
PreferenceUse Case
.requiredSensitive data (video, personal info) - Recommended
.optionalBalanced (encrypts if peer supports)
.noneMaximum performance (not recommended)

Implementation Guide

Step 1: MCPeerID Persistence

MCPeerID instances with the same displayName are NOT equal unless archived/unarchived:

swift
import KeychainAccess

class P2PStreamingManager {
    private let keychain = Keychain(service: Bundle.main.bundleIdentifier ?? "com.app")
        .accessibility(.whenUnlockedThisDeviceOnly)
    private let peerIDKey = "p2p_mcpeer_id"
    
    private func setupPeerID() {
        if let persistedPeerID = loadPersistedPeerID() {
            peerID = persistedPeerID
            return
        }
        
        let newPeerID = MCPeerID(displayName: UIDevice.current.name)
        peerID = newPeerID
        persistPeerID(newPeerID)
    }
    
    private func loadPersistedPeerID() -> MCPeerID? {
        guard let data = keychain[data: peerIDKey] else { return nil }
        return try? NSKeyedUnarchiver.unarchivedObject(
            ofClass: MCPeerID.self, 
            from: data
        )
    }
}

Step 2: Streamer (Advertiser) Setup

swift
class StreamerManager {
    private var peerID: MCPeerID!
    private var advertiser: MCNearbyServiceAdvertiser?
    private var viewerSessions: [String: MCSession] = []  // Per-viewer sessions
    
    private let serviceType = "carseet-stream"  // Max 15 chars
    
    func startAdvertising() {
        let discoveryInfo: [String: String] = [
            "role": "streamer",
            "deviceId": deviceId
        ]
        
        advertiser = MCNearbyServiceAdvertiser(
            peer: peerID,
            discoveryInfo: discoveryInfo,
            serviceType: serviceType
        )
        advertiser?.delegate = self
        advertiser?.startAdvertisingPeer()
    }
}

extension StreamerManager: MCNearbyServiceAdvertiserDelegate {
    func advertiser(_ advertiser: MCNearbyServiceAdvertiser,
                    didReceiveInvitationFromPeer peerID: MCPeerID,
                    withContext context: Data?,
                    invitationHandler: @escaping (Bool, MCSession?) -> Void) {
        
        // Per-viewer session architecture
        let viewerSession = MCSession(
            peer: self.peerID,
            securityIdentity: nil,
            encryptionPreference: .required
        )
        viewerSession.delegate = self
        
        // Store session for selective disconnect
        viewerSessions[credential.deviceId] = viewerSession
        
        invitationHandler(true, viewerSession)
    }
}

Step 3: Reliable Video Frame Streaming

swift
extension StreamerManager {
    func sendEncodedFrame(_ frame: EncodedVideoFrame) {
        let serializedData = frame.serialize()
        
        for (_, viewerSession) in viewerSessions {
            guard !viewerSession.connectedPeers.isEmpty else { continue }
            
            do {
                // Use .unreliable for video (lower latency, drops OK)
                // Use .reliable for control messages
                try viewerSession.send(
                    serializedData,
                    toPeers: viewerSession.connectedPeers,
                    with: .unreliable
                )
            } catch {
                print("Frame send error: \(error)")
            }
        }
    }
}

Step 4: Background Handling (Critical)

MPC does not function in background:

swift
class P2PManager {
    func setupAppLifecycleObservers() {
        NotificationCenter.default.addObserver(
            forName: UIApplication.didEnterBackgroundNotification,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            // MUST disconnect - MPC fails in background
            for (_, session) in self?.viewerSessions ?? [:] {
                session.disconnect()
            }
            self?.viewerSessions.removeAll()
        }
    }
}

Common Pitfalls

1. MCPeerID Equality Trap

swift
// ❌ WRONG - These are NOT equal even with same name
let peer1 = MCPeerID(displayName: "Device")
let peer2 = MCPeerID(displayName: "Device")
peer1 == peer2  // FALSE!

// ✅ CORRECT - Archive and persist, then unarchive
let archived = try NSKeyedArchiver.archivedData(withRootObject: peer1, requiringSecureCoding: true)
let restored = try NSKeyedUnarchiver.unarchivedObject(ofClass: MCPeerID.self, from: archived)
peer1 == restored  // TRUE!

2. Service Type Length Violation

swift
// ❌ WRONG - Too long (18 chars)
let serviceType = "my-awesome-service"  // Will fail silently

// ✅ CORRECT - Max 15 characters
let serviceType = "carseet-stream"  // 14 chars

3. Background Session Corruption

swift
// ❌ WRONG - Trying to keep session alive in background
func applicationDidEnterBackground() {
    // MPC WILL cause undefined behavior
}

// ✅ CORRECT - Disconnect immediately on background
func applicationDidEnterBackground() {
    session.disconnect()
}

4. Shared Session Disconnect Side Effects

swift
// ❌ WRONG - Shared session affects all peers
session.disconnect()  // Drops ALL connected viewers

// ✅ CORRECT - Per-viewer sessions for selective disconnect
func disconnectViewer(deviceId: String) {
    viewerSessions[deviceId]?.disconnect()
    viewerSessions.removeValue(forKey: deviceId)
}

References


Derived from CarSeet project - P2PStreamingManager.swift