2 Commits

Author SHA1 Message Date
0c1e3d6fff Refactor networking to use Network.framework
Replace MultipeerConnectivity with a custom P2P implementation using Network.framework (NWListener/NWConnection) and CoreBluetooth for discovery.
- Add P2PConnectionManager, BLEDiscoveryManager, and NetworkFraming.
- Add ConnectedPeer and DiscoveredHost models.
- Update Info.plist with local network and bluetooth permissions.
- Refactor Views to use P2PConnectionManager.
- Add implementation plan and transition docs.
2026-01-20 23:48:14 -05:00
ad2b451cb1 Add git revert instructions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:54:54 -05:00
17 changed files with 2753 additions and 105 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(./check_build.sh:*)"
]
}
}

View File

@@ -11,19 +11,13 @@ import SwiftData
@main
struct BeamScribeApp: App {
@StateObject private var sessionState = SessionState()
@StateObject private var transcriptionManager = TranscriptionManager()
@StateObject private var multipeerManager = MultipeerManager()
@StateObject private var fileManager = FileStorageManager()
@StateObject private var subscriptionManager = SubscriptionManager()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(sessionState)
.environmentObject(transcriptionManager)
.environmentObject(multipeerManager)
.environmentObject(fileManager)
.environmentObject(subscriptionManager)
// Enforce light mode for now to ensure consistency
.preferredColorScheme(.light)

View File

@@ -7,14 +7,14 @@
import SwiftUI
import Combine
import MultipeerConnectivity
import Network
import AVFoundation
struct ContentView: View {
@EnvironmentObject var sessionState: SessionState
@StateObject private var transcriptionManager = TranscriptionManager()
@StateObject private var multipeerManager = MultipeerManager()
@StateObject private var p2pManager = P2PConnectionManager()
@StateObject private var fileManager = FileStorageManager()
@StateObject private var audioManager = AudioSessionManager()
@StateObject private var settings = SettingsModel()
@@ -30,7 +30,7 @@ struct ContentView: View {
case .hostSetup:
HostSetupView(
transcriptionManager: transcriptionManager,
multipeerManager: multipeerManager,
p2pManager: p2pManager,
fileManager: fileManager
)
.transition(.move(edge: .trailing))
@@ -44,7 +44,7 @@ struct ContentView: View {
case .guestBrowsing:
GuestBrowserView(
multipeerManager: multipeerManager,
p2pManager: p2pManager,
fileManager: fileManager
)
.transition(.move(edge: .trailing))
@@ -52,7 +52,7 @@ struct ContentView: View {
case .activeSession:
TranscriptView(
transcriptionManager: transcriptionManager,
multipeerManager: multipeerManager,
p2pManager: p2pManager,
fileManager: fileManager,
audioManager: audioManager
)
@@ -72,87 +72,85 @@ struct ContentView: View {
}
// MARK: - Setup
private func setupCallbacks() {
// Handle transcription results (Host)
transcriptionManager.onPartialResult = { [weak sessionState] text in
guard let sessionState = sessionState else { return }
// Broadcast partial result to guests (skip if solo mode)
if !sessionState.isSoloMode {
multipeerManager.sendLiveChunk(
p2pManager.sendLiveChunk(
text: text,
eventName: sessionState.eventName,
isFinal: false
)
}
}
transcriptionManager.onFinalResult = { [weak sessionState] text in
guard let sessionState = sessionState else { return }
// Add to local state
Task { @MainActor in
sessionState.addSegment(TranscriptSegment(text: text, isFinal: true))
}
// Save to file
fileManager.appendText(text)
// Broadcast to guests (skip if solo mode)
if !sessionState.isSoloMode {
multipeerManager.sendLiveChunk(
p2pManager.sendLiveChunk(
text: text,
eventName: sessionState.eventName,
isFinal: true
)
}
}
transcriptionManager.onSessionResumed = { [weak sessionState] in
guard let sessionState = sessionState else { return }
Task { @MainActor in
sessionState.insertResumedMarker()
}
// Broadcast resume marker to guests (skip if solo mode)
if !sessionState.isSoloMode {
multipeerManager.sendAlert(
p2pManager.sendAlert(
type: .sessionResumed,
eventName: sessionState.eventName
)
}
// Also append to file
let formatter = DateFormatter()
formatter.timeStyle = .short
let marker = "[Session Resumed at \(formatter.string(from: Date()))]"
fileManager.appendMarker(marker)
}
// Handle received packets (Guest)
multipeerManager.onPacketReceived = { [weak sessionState, weak transcriptionManager] packet in
p2pManager.onPacketReceived = { [weak sessionState, weak transcriptionManager] packet in
guard let sessionState = sessionState, let transcriptionManager = transcriptionManager else { return }
Task { @MainActor in
switch packet.type {
case .fullHistory:
if let text = packet.text {
sessionState.loadFullHistory(text)
// Sync start time from host (using relative duration to avoid clock skew)
if let duration = packet.currentSessionDuration {
let calculatedStartTime = Date().addingTimeInterval(-duration)
sessionState.startTime = calculatedStartTime
}
// Cancel pending appends to avoid race conditions? No, just overwrite.
// Overwrite file to avoid duplication if full history is received multiple times
fileManager.overwriteCurrentFile(with: text, eventName: sessionState.eventName)
}
case .liveChunk:
if let text = packet.text {
let isFinal = packet.isFinal ?? true
@@ -165,7 +163,7 @@ struct ContentView: View {
sessionState.updateLastPartialSegment(text, isFinal: false)
}
}
case .alert:
switch packet.alertType {
case .hostDisconnected:
@@ -181,39 +179,52 @@ struct ContentView: View {
}
}
}
// Handle host lost (Guest)
multipeerManager.onHostLost = { [weak sessionState, weak transcriptionManager] in
p2pManager.onHostLost = { [weak sessionState, weak transcriptionManager] in
guard let sessionState = sessionState, let transcriptionManager = transcriptionManager else { return }
Task { @MainActor in
sessionState.showHostLostBanner = true
sessionState.isConnectedToHost = false
// Freeze the timer
transcriptionManager.sessionEndTime = Date()
}
}
// Handle host connected (Guest) - clears "host lost" state on rejoin
p2pManager.onHostConnected = { [weak sessionState, weak transcriptionManager] in
guard let sessionState = sessionState, let transcriptionManager = transcriptionManager else { return }
Task { @MainActor in
sessionState.showHostLostBanner = false
sessionState.isConnectedToHost = true
// Resume the timer by clearing the end time
transcriptionManager.sessionEndTime = nil
}
}
// Handle peer count changes (UI update only)
multipeerManager.onPeerCountChanged = { [weak sessionState] count in
p2pManager.onPeerCountChanged = { [weak sessionState] count in
guard let sessionState = sessionState else { return }
Task { @MainActor in
sessionState.connectedPeerCount = count
}
}
// Handle specific peer join (Send History)
multipeerManager.onPeerJoined = { [weak sessionState] peerID in
p2pManager.onPeerJoined = { [weak sessionState] peerID in
guard let sessionState = sessionState else { return }
Task { @MainActor in
// If host, send full history to the new peer
if sessionState.userRole == .host {
print("New peer joined: \(peerID.displayName). Sending full history.")
multipeerManager.sendFullHistory(
print("New peer joined: \(peerID). Sending full history.")
p2pManager.sendFullHistory(
to: peerID,
text: sessionState.fullTranscriptText,
eventName: sessionState.eventName,

View File

@@ -12,6 +12,14 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>BeamScribe uses Bluetooth to quickly discover nearby sessions.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>BeamScribe uses Bluetooth to advertise your session to nearby devices.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>BeamScribe uses the local network to connect hosts and guests for live transcription.</string>
</dict>
</plist>

View File

@@ -0,0 +1,211 @@
//
// BLEDiscoveryManager.swift
// BeamScribe
//
// CoreBluetooth-based fast discovery layer.
// BLE advertisement is near-instant and "wakes" the AWDL interface for faster
// Network.framework discovery.
//
import Foundation
import CoreBluetooth
import Combine
@MainActor
class BLEDiscoveryManager: NSObject, ObservableObject {
// MARK: - Published Properties
@Published var isAdvertising: Bool = false
@Published var isScanning: Bool = false
// MARK: - Callbacks
/// Called when a BeamScribe host is discovered via BLE
var onHostDiscovered: ((String) -> Void)?
// MARK: - Private Properties
/// BeamScribe service UUID for BLE discovery
private let serviceUUID = CBUUID(string: "B34E5C01-BE00-4000-8000-000000000001")
/// Peripheral manager for advertising (host mode)
private var peripheralManager: CBPeripheralManager?
/// Central manager for scanning (guest mode)
private var centralManager: CBCentralManager?
/// Current event name being advertised
private var eventName: String = ""
/// Discovered peripheral IDs to prevent duplicate callbacks
private var discoveredPeripherals: Set<UUID> = []
// MARK: - Initialization
override init() {
super.init()
}
// MARK: - Host Mode (Peripheral)
func startAdvertising(eventName: String) {
guard peripheralManager == nil else {
// Already advertising, just update event name
if isAdvertising {
stopAdvertising()
self.eventName = eventName
startAdvertising(eventName: eventName)
}
return
}
self.eventName = eventName
peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
// Advertising will start once peripheral manager is powered on
}
func stopAdvertising() {
peripheralManager?.stopAdvertising()
peripheralManager = nil
isAdvertising = false
}
private func beginAdvertising() {
guard let manager = peripheralManager, manager.state == .poweredOn else {
return
}
// BLE advertisement data has a 28-byte limit for local name
// Truncate event name to fit
let truncatedName = String(eventName.prefix(8))
let advertisementData: [String: Any] = [
CBAdvertisementDataServiceUUIDsKey: [serviceUUID],
CBAdvertisementDataLocalNameKey: truncatedName
]
manager.startAdvertising(advertisementData)
isAdvertising = true
print("BLE advertising started for: \(truncatedName)")
}
// MARK: - Guest Mode (Central)
func startScanning() {
guard centralManager == nil else {
return
}
discoveredPeripherals.removeAll()
centralManager = CBCentralManager(delegate: self, queue: nil)
// Scanning will start once central manager is powered on
}
func stopScanning() {
centralManager?.stopScan()
centralManager = nil
isScanning = false
discoveredPeripherals.removeAll()
}
private func beginScanning() {
guard let manager = centralManager, manager.state == .poweredOn else {
return
}
// Scan specifically for BeamScribe service
manager.scanForPeripherals(
withServices: [serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)
isScanning = true
print("BLE scanning started")
}
}
// MARK: - CBPeripheralManagerDelegate
extension BLEDiscoveryManager: CBPeripheralManagerDelegate {
nonisolated func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
Task { @MainActor in
switch peripheral.state {
case .poweredOn:
print("Peripheral manager powered on")
beginAdvertising()
case .poweredOff:
print("Bluetooth is powered off")
isAdvertising = false
case .unauthorized:
print("Bluetooth unauthorized")
isAdvertising = false
case .unsupported:
print("Bluetooth not supported")
isAdvertising = false
default:
break
}
}
}
nonisolated func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
Task { @MainActor in
if let error = error {
print("Failed to start BLE advertising: \(error)")
isAdvertising = false
} else {
print("BLE advertising started successfully")
isAdvertising = true
}
}
}
}
// MARK: - CBCentralManagerDelegate
extension BLEDiscoveryManager: CBCentralManagerDelegate {
nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
Task { @MainActor in
switch central.state {
case .poweredOn:
print("Central manager powered on")
beginScanning()
case .poweredOff:
print("Bluetooth is powered off")
isScanning = false
case .unauthorized:
print("Bluetooth unauthorized")
isScanning = false
case .unsupported:
print("Bluetooth not supported")
isScanning = false
default:
break
}
}
}
nonisolated func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
Task { @MainActor in
// Avoid duplicate discoveries
guard !discoveredPeripherals.contains(peripheral.identifier) else {
return
}
discoveredPeripherals.insert(peripheral.identifier)
// Extract the advertised name (event name hint)
let hostName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
?? peripheral.name
?? "Unknown Host"
print("BLE discovered BeamScribe host: \(hostName) (RSSI: \(RSSI))")
// Notify delegate - this primes AWDL for faster Network.framework discovery
onHostDiscovered?(hostName)
}
}
}

View File

@@ -363,9 +363,6 @@ extension MultipeerManager: MCSessionDelegate {
self.onPeerCountChanged?(self.connectedPeers.count)
case .notConnected:
let wasConnected = self.connectedPeers.contains(peerID)
let wasStable = self.stablePeers.contains(peerID)
// Check if this is an early disconnect (within unstable threshold)
var shouldRetry = false
if let connectedAt = self.connectedAtTimes[peerName] {

View File

@@ -0,0 +1,159 @@
//
// NetworkFraming.swift
// BeamScribe
//
// Length-prefixed TCP framing utilities for Network.framework.
// Implements 4-byte big-endian length prefix for message framing.
//
import Foundation
import Network
/// Utilities for framing TCP messages with length prefixes
enum NetworkFraming {
/// Header size in bytes (UInt32 = 4 bytes)
static let headerSize = 4
/// Maximum allowed message size (10 MB)
static let maxMessageSize: UInt32 = 10_485_760
/// Frame a packet for transmission by prepending a 4-byte length header
/// - Parameter packet: The TranscriptPacket to frame
/// - Returns: Framed data with length prefix, or nil if encoding fails
static func framePacket(_ packet: TranscriptPacket) -> Data? {
guard let payload = try? packet.encode() else {
return nil
}
return frameData(payload)
}
/// Frame raw data for transmission by prepending a 4-byte length header
/// - Parameter data: The data to frame
/// - Returns: Framed data with length prefix
static func frameData(_ data: Data) -> Data {
var length = UInt32(data.count).bigEndian
var framedData = Data(bytes: &length, count: headerSize)
framedData.append(data)
return framedData
}
/// Send a framed packet over an NWConnection
/// - Parameters:
/// - packet: The packet to send
/// - connection: The connection to send over
/// - completion: Called when send completes or fails
static func send(_ packet: TranscriptPacket, over connection: NWConnection, completion: @escaping (Error?) -> Void) {
guard let framedData = framePacket(packet) else {
completion(FramingError.encodingFailed)
return
}
connection.send(content: framedData, completion: .contentProcessed { error in
completion(error)
})
}
/// Receive a framed packet from an NWConnection
/// - Parameters:
/// - connection: The connection to receive from
/// - completion: Called with the decoded packet or error
static func receivePacket(from connection: NWConnection, completion: @escaping (Result<TranscriptPacket, Error>) -> Void) {
// First, read the 4-byte length header
connection.receive(minimumIncompleteLength: headerSize, maximumLength: headerSize) { headerData, _, isComplete, error in
if let error = error {
completion(.failure(error))
return
}
guard let headerData = headerData, headerData.count == headerSize else {
if isComplete {
completion(.failure(FramingError.connectionClosed))
} else {
completion(.failure(FramingError.incompleteHeader))
}
return
}
// Parse the length from the header
let length = headerData.withUnsafeBytes { bytes in
bytes.load(as: UInt32.self).bigEndian
}
// Validate message size
guard length > 0, length <= maxMessageSize else {
completion(.failure(FramingError.invalidMessageSize(length)))
return
}
// Read the payload
connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { payloadData, _, isComplete, error in
if let error = error {
completion(.failure(error))
return
}
guard let payloadData = payloadData, payloadData.count == Int(length) else {
if isComplete {
completion(.failure(FramingError.connectionClosed))
} else {
completion(.failure(FramingError.incompletePayload))
}
return
}
// Decode the packet
do {
let packet = try TranscriptPacket.decode(from: payloadData)
completion(.success(packet))
} catch {
completion(.failure(FramingError.decodingFailed(error)))
}
}
}
}
/// Start a continuous receive loop for a connection
/// - Parameters:
/// - connection: The connection to receive from
/// - onPacket: Called for each received packet
/// - onError: Called if an error occurs (connection will stop receiving)
static func startReceiving(from connection: NWConnection, onPacket: @escaping (TranscriptPacket) -> Void, onError: @escaping (Error) -> Void) {
receivePacket(from: connection) { result in
switch result {
case .success(let packet):
onPacket(packet)
// Continue receiving
startReceiving(from: connection, onPacket: onPacket, onError: onError)
case .failure(let error):
onError(error)
}
}
}
}
/// Errors specific to network framing
enum FramingError: LocalizedError {
case encodingFailed
case incompleteHeader
case incompletePayload
case invalidMessageSize(UInt32)
case decodingFailed(Error)
case connectionClosed
var errorDescription: String? {
switch self {
case .encodingFailed:
return "Failed to encode packet"
case .incompleteHeader:
return "Incomplete message header received"
case .incompletePayload:
return "Incomplete message payload received"
case .invalidMessageSize(let size):
return "Invalid message size: \(size) bytes"
case .decodingFailed(let error):
return "Failed to decode packet: \(error.localizedDescription)"
case .connectionClosed:
return "Connection closed unexpectedly"
}
}
}

View File

@@ -0,0 +1,663 @@
//
// P2PConnectionManager.swift
// BeamScribe
//
// Network.framework-based peer-to-peer connection manager.
// Replaces MultipeerConnectivity with NWListener (host) and NWBrowser (guest).
//
import Foundation
import Network
import Combine
import UIKit
@MainActor
class P2PConnectionManager: NSObject, ObservableObject {
// MARK: - Published Properties
/// Available hosts discovered via browsing (endpointID -> DiscoveredHost)
@Published var availableHosts: [String: DiscoveredHost] = [:]
/// Connected peers (for host: guests; for guest: just the host)
@Published var connectedPeers: [UUID: ConnectedPeer] = [:]
/// Whether currently hosting a session
@Published var isHosting: Bool = false
/// Whether currently browsing for hosts
@Published var isBrowsing: Bool = false
/// Whether connected to a host (guest mode)
@Published var isConnectedToHost: Bool = false
/// Current connection status string for UI
@Published var connectionStatus: String = ""
// MARK: - Callbacks
/// Called when a packet is received
var onPacketReceived: ((TranscriptPacket) -> Void)?
/// Called when peer count changes
var onPeerCountChanged: ((Int) -> Void)?
/// Called when a new peer joins and stabilizes (host sends history)
var onPeerJoined: ((UUID) -> Void)?
/// Called when host connection is lost (guest mode)
var onHostLost: (() -> Void)?
/// Called when successfully connected to host (guest mode) - use to clear "host lost" state
var onHostConnected: (() -> Void)?
/// Called when connection fails after all retries
var onConnectionFailed: ((DiscoveredHost) -> Void)?
// MARK: - Private Properties
private let serviceType = "_beamscribe._tcp"
private var eventName: String = ""
// Host mode
private var listener: NWListener?
private var guestConnections: [UUID: NWConnection] = [:]
private var stablePeers: Set<UUID> = []
// Guest mode
private var browser: NWBrowser?
private var hostConnection: NWConnection?
private var currentHost: DiscoveredHost?
// BLE Discovery integration
private var bleDiscoveryManager: BLEDiscoveryManager?
// MARK: - Connection Configuration
/// Timeout for connection attempts
private let connectionTimeout: TimeInterval = 30
/// Delay before considering a peer stable (traffic gate)
private let stabilizationDelay: UInt64 = 4_000_000_000 // 4 seconds
/// Maximum retry attempts for failed connections
private let maxRetryAttempts = 3
/// Time threshold to consider connection unstable if it drops
private let unstableConnectionThreshold: TimeInterval = 5.0
// MARK: - Connection Retry State
private var connectionAttempts: [String: Int] = [:] // endpointID -> attempt count
private var connectionStartTimes: [String: Date] = [:]
private var connectedAtTimes: [String: Date] = [:]
private var pendingRetry: [String: Bool] = [:]
private var connectionTimeoutTask: Task<Void, Never>?
// MARK: - Initialization
override init() {
super.init()
setupBLEDiscovery()
}
private func setupBLEDiscovery() {
bleDiscoveryManager = BLEDiscoveryManager()
bleDiscoveryManager?.onHostDiscovered = { hostName in
// BLE discovery primes AWDL, no action needed here
// The browser will find the host shortly after
print("BLE discovered host hint: \(hostName)")
}
}
// MARK: - Network Parameters
private func createNetworkParameters() -> NWParameters {
let parameters = NWParameters.tcp
parameters.includePeerToPeer = true
parameters.serviceClass = .responsiveData
// Allow local network
let options = NWProtocolTCP.Options()
options.enableKeepalive = true
options.keepaliveInterval = 30
parameters.defaultProtocolStack.transportProtocol = options
return parameters
}
// MARK: - Host Mode
func startHosting(eventName: String) {
stopHosting()
self.eventName = eventName
print("Starting hosting for event: \(eventName)")
do {
let parameters = createNetworkParameters()
listener = try NWListener(using: parameters)
// Configure Bonjour service advertisement with device name in TXT record
let deviceName = UIDevice.current.name
// Create TXT record data manually: length-prefixed key=value format
let txtEntry = "deviceName=\(deviceName)"
var txtData = Data()
txtData.append(UInt8(txtEntry.utf8.count))
txtData.append(contentsOf: txtEntry.utf8)
listener?.service = NWListener.Service(
name: eventName,
type: serviceType,
txtRecord: txtData
)
listener?.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { @MainActor in
self.handleListenerStateUpdate(state)
}
}
listener?.newConnectionHandler = { [weak self] connection in
guard let self else { return }
Task { @MainActor in
self.handleNewGuestConnection(connection)
}
}
listener?.start(queue: .main)
isHosting = true
// Start BLE advertising for fast discovery
bleDiscoveryManager?.startAdvertising(eventName: eventName)
} catch {
print("Failed to start listener: \(error)")
connectionStatus = "Failed to start hosting: \(error.localizedDescription)"
}
}
func stopHosting() {
print("Stopping hosting")
listener?.cancel()
listener = nil
// Close all guest connections
for (_, connection) in guestConnections {
connection.cancel()
}
guestConnections.removeAll()
connectedPeers.removeAll()
stablePeers.removeAll()
isHosting = false
bleDiscoveryManager?.stopAdvertising()
}
private func handleListenerStateUpdate(_ state: NWListener.State) {
switch state {
case .ready:
print("Listener ready, advertising as: \(eventName)")
connectionStatus = "Hosting: \(eventName)"
case .failed(let error):
print("Listener failed: \(error)")
connectionStatus = "Hosting failed: \(error.localizedDescription)"
isHosting = false
case .cancelled:
print("Listener cancelled")
isHosting = false
default:
break
}
}
private func handleNewGuestConnection(_ connection: NWConnection) {
let peerID = UUID()
let peerName = connection.endpoint.debugDescription
print("New guest connection from: \(peerName)")
guestConnections[peerID] = connection
let peer = ConnectedPeer(displayName: peerName, isStable: false)
connectedPeers[peerID] = peer
connectedAtTimes[peerID.uuidString] = Date()
connection.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { @MainActor in
self.handleGuestConnectionStateUpdate(state, peerID: peerID, peerName: peerName)
}
}
connection.start(queue: .main)
// Start receiving data from this guest
NetworkFraming.startReceiving(from: connection) { [weak self] packet in
Task { @MainActor in
self?.onPacketReceived?(packet)
}
} onError: { [weak self] error in
Task { @MainActor in
print("Error receiving from guest \(peerName): \(error)")
self?.removeGuestConnection(peerID: peerID)
}
}
onPeerCountChanged?(connectedPeers.count)
// Start stabilization timer
startStabilizationTimer(for: peerID)
}
private func handleGuestConnectionStateUpdate(_ state: NWConnection.State, peerID: UUID, peerName: String) {
switch state {
case .ready:
print("Guest \(peerName) connection ready")
case .failed(let error):
print("Guest \(peerName) connection failed: \(error)")
removeGuestConnection(peerID: peerID)
case .cancelled:
print("Guest \(peerName) connection cancelled")
removeGuestConnection(peerID: peerID)
default:
break
}
}
private func removeGuestConnection(peerID: UUID) {
guestConnections[peerID]?.cancel()
guestConnections.removeValue(forKey: peerID)
connectedPeers.removeValue(forKey: peerID)
stablePeers.remove(peerID)
connectedAtTimes.removeValue(forKey: peerID.uuidString)
onPeerCountChanged?(connectedPeers.count)
}
private func startStabilizationTimer(for peerID: UUID) {
Task { @MainActor in
try? await Task.sleep(nanoseconds: stabilizationDelay)
guard connectedPeers[peerID] != nil else { return }
stablePeers.insert(peerID)
connectedPeers[peerID]?.isStable = true
onPeerJoined?(peerID)
print("Peer \(peerID) is now stable")
}
}
// MARK: - Guest Mode
func startBrowsing() {
stopBrowsing()
print("Starting browsing")
availableHosts.removeAll()
let parameters = createNetworkParameters()
browser = NWBrowser(for: .bonjour(type: serviceType, domain: nil), using: parameters)
browser?.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { @MainActor in
self.handleBrowserStateUpdate(state)
}
}
browser?.browseResultsChangedHandler = { [weak self] results, changes in
guard let self else { return }
Task { @MainActor in
self.handleBrowseResultsChanged(results: results, changes: changes)
}
}
browser?.start(queue: .main)
isBrowsing = true
// Start BLE scanning for fast discovery
bleDiscoveryManager?.startScanning()
}
func stopBrowsing() {
browser?.cancel()
browser = nil
isBrowsing = false
bleDiscoveryManager?.stopScanning()
}
private func handleBrowserStateUpdate(_ state: NWBrowser.State) {
switch state {
case .ready:
print("Browser ready")
connectionStatus = "Searching for hosts..."
case .failed(let error):
print("Browser failed: \(error)")
connectionStatus = "Search failed: \(error.localizedDescription)"
case .cancelled:
print("Browser cancelled")
default:
break
}
}
private func handleBrowseResultsChanged(results: Set<NWBrowser.Result>, changes: Set<NWBrowser.Result.Change>) {
for change in changes {
switch change {
case .added(let result):
handleHostFound(result)
case .removed(let result):
handleHostLost(result)
case .changed(old: _, new: let newResult, flags: _):
// Update the host info
handleHostFound(newResult)
case .identical:
break
@unknown default:
break
}
}
}
private func handleHostFound(_ result: NWBrowser.Result) {
guard case .service(let name, let type, let domain, _) = result.endpoint else {
return
}
let endpointID = "\(name).\(type).\(domain)"
let deviceName = extractDeviceName(from: result)
print("Found host: \(name) on \(deviceName)")
// Remove any existing entry with same device name (host might have restarted)
availableHosts = availableHosts.filter { $0.value.deviceName != deviceName }
let host = DiscoveredHost(
deviceName: deviceName,
eventName: name,
endpointID: endpointID
)
availableHosts[endpointID] = host
}
private func handleHostLost(_ result: NWBrowser.Result) {
guard case .service(let name, let type, let domain, _) = result.endpoint else {
return
}
let endpointID = "\(name).\(type).\(domain)"
print("Lost host: \(name)")
availableHosts.removeValue(forKey: endpointID)
}
private func extractDeviceName(from result: NWBrowser.Result) -> String {
// Extract device name from TXT record metadata
if case .bonjour(let txtRecord) = result.metadata {
if let deviceName = txtRecord["deviceName"] {
return deviceName
}
}
// Fallback: use endpoint description
return result.endpoint.debugDescription
}
func joinHost(_ host: DiscoveredHost) {
let attempt = (connectionAttempts[host.endpointID] ?? 0) + 1
connectionAttempts[host.endpointID] = attempt
connectionStatus = "Joining \(host.eventName) (attempt \(attempt)/\(maxRetryAttempts))..."
currentHost = host
connectionStartTimes[host.endpointID] = Date()
// Create connection to host
let endpoint = NWEndpoint.service(
name: host.eventName,
type: serviceType,
domain: "local",
interface: nil
)
let parameters = createNetworkParameters()
hostConnection = NWConnection(to: endpoint, using: parameters)
hostConnection?.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { @MainActor in
self.handleHostConnectionStateUpdate(state, host: host)
}
}
hostConnection?.start(queue: .main)
// Start connection watchdog
startConnectionWatchdog(for: host, attempt: attempt)
}
private func handleHostConnectionStateUpdate(_ state: NWConnection.State, host: DiscoveredHost) {
switch state {
case .ready:
cancelConnectionWatchdog()
connectionStatus = "Connected to \(host.eventName)!"
connectedAtTimes[host.endpointID] = Date()
pendingRetry[host.endpointID] = false
isConnectedToHost = true
// Notify that we're connected (clears "host lost" state on rejoin)
onHostConnected?()
// Create peer entry for the host
let peerID = UUID()
let peer = ConnectedPeer(displayName: host.deviceName, isStable: true)
connectedPeers[peerID] = peer
// Start receiving data from host
if let connection = hostConnection {
NetworkFraming.startReceiving(from: connection) { [weak self] packet in
Task { @MainActor in
self?.onPacketReceived?(packet)
}
} onError: { [weak self] error in
Task { @MainActor in
print("Error receiving from host: \(error)")
self?.handleHostDisconnect(host: host)
}
}
}
onPeerCountChanged?(1)
// Stop BLE scanning - we're connected
bleDiscoveryManager?.stopScanning()
case .failed(let error):
print("Host connection failed: \(error)")
handleHostConnectionFailure(host: host, error: error)
case .cancelled:
print("Host connection cancelled")
if isConnectedToHost {
handleHostDisconnect(host: host)
}
case .waiting(let error):
print("Host connection waiting: \(error)")
connectionStatus = "Waiting for host response..."
default:
break
}
}
private func handleHostConnectionFailure(host: DiscoveredHost, error: NWError) {
cancelConnectionWatchdog()
// Check if this was an early disconnect
var shouldRetry = false
if let connectedAt = connectedAtTimes[host.endpointID] {
let duration = Date().timeIntervalSince(connectedAt)
if duration < unstableConnectionThreshold {
shouldRetry = true
}
} else {
// Never connected successfully
shouldRetry = true
}
if shouldRetry && pendingRetry[host.endpointID] != true {
retryJoinHost(host)
} else if !shouldRetry {
isConnectedToHost = false
connectedPeers.removeAll()
onHostLost?()
}
}
private func handleHostDisconnect(host: DiscoveredHost) {
isConnectedToHost = false
connectedPeers.removeAll()
hostConnection?.cancel()
hostConnection = nil
currentHost = nil
onHostLost?()
// Resume BLE scanning for reconnection
bleDiscoveryManager?.startScanning()
}
private func startConnectionWatchdog(for host: DiscoveredHost, attempt: Int) {
connectionTimeoutTask?.cancel()
let watchdogTimeout = connectionTimeout + 5
connectionTimeoutTask = Task { @MainActor in
for elapsed in stride(from: 5, through: Int(watchdogTimeout), by: 5) {
try? await Task.sleep(nanoseconds: 5_000_000_000)
guard !Task.isCancelled else { return }
guard currentHost?.endpointID == host.endpointID else { return }
guard !isConnectedToHost else {
connectionStatus = "Connected!"
return
}
connectionStatus = "Waiting for response... (\(elapsed)s)"
}
guard !Task.isCancelled else { return }
guard currentHost?.endpointID == host.endpointID else { return }
guard !isConnectedToHost else { return }
connectionStatus = "Connection timed out, retrying..."
retryJoinHost(host)
}
}
private func cancelConnectionWatchdog() {
connectionTimeoutTask?.cancel()
connectionTimeoutTask = nil
}
private func retryJoinHost(_ host: DiscoveredHost) {
let attempts = connectionAttempts[host.endpointID] ?? 0
guard attempts < maxRetryAttempts else {
pendingRetry[host.endpointID] = false
onConnectionFailed?(host)
return
}
// Exponential backoff: 1s, 2s, 4s
let backoffSeconds = pow(2.0, Double(attempts - 1))
pendingRetry[host.endpointID] = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(backoffSeconds * 1_000_000_000))
guard pendingRetry[host.endpointID] == true else { return }
guard currentHost?.endpointID == host.endpointID else { return }
// Check if host is still available
if let currentHostInfo = availableHosts[host.endpointID] {
joinHost(currentHostInfo)
} else {
pendingRetry[host.endpointID] = false
onConnectionFailed?(host)
}
}
}
func cancelRetry(for endpointID: String) {
pendingRetry[endpointID] = false
connectionAttempts[endpointID] = 0
}
// MARK: - Data Transmission
func broadcastPacket(_ packet: TranscriptPacket) {
// Traffic Gate: Only send to stable peers
let stableConnections = guestConnections.filter { stablePeers.contains($0.key) }
guard !stableConnections.isEmpty else { return }
for (peerID, connection) in stableConnections {
NetworkFraming.send(packet, over: connection) { error in
if let error = error {
print("Failed to send to peer \(peerID): \(error)")
}
}
}
}
func sendFullHistory(to peerID: UUID, text: String, eventName: String, startTime: Date?) {
guard let connection = guestConnections[peerID], stablePeers.contains(peerID) else {
return
}
var duration: TimeInterval? = nil
if let start = startTime {
duration = Date().timeIntervalSince(start)
}
let packet = TranscriptPacket(
type: .fullHistory,
text: text,
eventName: eventName,
currentSessionDuration: duration
)
NetworkFraming.send(packet, over: connection) { error in
if let error = error {
print("Failed to send full history: \(error)")
}
}
}
func sendLiveChunk(text: String, eventName: String, isFinal: Bool) {
let packet = TranscriptPacket(
type: .liveChunk,
text: text,
eventName: eventName,
isFinal: isFinal
)
broadcastPacket(packet)
}
func sendAlert(type: AlertType, eventName: String) {
let packet = TranscriptPacket(
type: .alert,
text: nil,
eventName: eventName,
alertType: type
)
broadcastPacket(packet)
}
// MARK: - Disconnect
func disconnect() {
print("Disconnecting all peers")
cancelConnectionWatchdog()
stopHosting()
stopBrowsing()
hostConnection?.cancel()
hostConnection = nil
connectedPeers.removeAll()
availableHosts.removeAll()
isConnectedToHost = false
currentHost = nil
connectionStatus = ""
}
}

View File

@@ -0,0 +1,47 @@
//
// ConnectedPeer.swift
// BeamScribe
//
// Represents a connected peer for Network.framework-based connections.
//
import Foundation
import Network
/// Represents a connected peer (guest from host's perspective, or host from guest's perspective)
struct ConnectedPeer: Identifiable, Hashable {
/// Unique identifier for this peer
let id: UUID
/// Display name of the peer device
let displayName: String
/// Timestamp when connection was established
let connectedAt: Date
/// Whether the peer has been stable long enough to receive data
var isStable: Bool
init(displayName: String, isStable: Bool = false) {
self.id = UUID()
self.displayName = displayName
self.connectedAt = Date()
self.isStable = isStable
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: ConnectedPeer, rhs: ConnectedPeer) -> Bool {
lhs.id == rhs.id
}
}
/// Connection state for the P2P manager
enum P2PConnectionState {
case idle
case hosting
case browsing
case connecting
case connected
case disconnected
case failed(Error)
}

View File

@@ -0,0 +1,38 @@
//
// DiscoveredHost.swift
// BeamScribe
//
// Represents a discovered host for Network.framework-based discovery.
//
import Foundation
/// Represents a host discovered via NWBrowser or BLE
struct DiscoveredHost: Identifiable, Hashable {
/// Unique identifier for this host discovery
let id: UUID
/// The device name of the host
let deviceName: String
/// The event name being hosted
let eventName: String
/// Timestamp when discovered
let discoveredAt: Date
/// The endpoint identifier from NWBrowser (used for connection)
let endpointID: String
init(deviceName: String, eventName: String, endpointID: String) {
self.id = UUID()
self.deviceName = deviceName
self.eventName = eventName
self.discoveredAt = Date()
self.endpointID = endpointID
}
func hash(into hasher: inout Hasher) {
hasher.combine(endpointID)
}
static func == (lhs: DiscoveredHost, rhs: DiscoveredHost) -> Bool {
lhs.endpointID == rhs.endpointID
}
}

View File

@@ -6,15 +6,15 @@
//
import SwiftUI
import MultipeerConnectivity
import Network
struct GuestBrowserView: View {
@EnvironmentObject var sessionState: SessionState
@ObservedObject var multipeerManager: MultipeerManager
@ObservedObject var p2pManager: P2PConnectionManager
@ObservedObject var fileManager: FileStorageManager
@State private var isConnecting: Bool = false
@State private var selectedHost: MCPeerID?
@State private var selectedHost: DiscoveredHost?
@State private var connectionFailed: Bool = false
var body: some View {
@@ -61,35 +61,35 @@ struct GuestBrowserView: View {
}
// Host List
if multipeerManager.availableHosts.isEmpty {
if p2pManager.availableHosts.isEmpty {
Spacer()
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.5)
Text("Searching for active sessions")
.font(.callout)
.foregroundColor(.secondary)
Text("Make sure the host has started transcribing")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(Array(multipeerManager.availableHosts.keys), id: \.self) { peerID in
ForEach(Array(p2pManager.availableHosts.values), id: \.id) { host in
HostRow(
eventName: multipeerManager.availableHosts[peerID] ?? "Unknown",
deviceName: peerID.displayName,
isConnecting: isConnecting && selectedHost == peerID,
statusMessage: selectedHost == peerID ? multipeerManager.connectionStatus : ""
eventName: host.eventName,
deviceName: host.deviceName,
isConnecting: isConnecting && selectedHost?.id == host.id,
statusMessage: selectedHost?.id == host.id ? p2pManager.connectionStatus : ""
) {
joinHost(peerID)
joinHost(host)
}
}
}
@@ -101,9 +101,9 @@ struct GuestBrowserView: View {
Button(action: {
// Cancel any pending retries
if let host = selectedHost {
multipeerManager.cancelRetry(for: host.displayName)
p2pManager.cancelRetry(for: host.endpointID)
}
multipeerManager.stopBrowsing()
p2pManager.stopBrowsing()
withAnimation {
sessionState.appPhase = .roleSelection
}
@@ -116,45 +116,44 @@ struct GuestBrowserView: View {
}
}
.onAppear {
multipeerManager.startBrowsing()
p2pManager.startBrowsing()
setupConnectionFailedHandler()
}
.onDisappear {
if sessionState.appPhase != .activeSession {
multipeerManager.stopBrowsing()
p2pManager.stopBrowsing()
}
}
.onChange(of: multipeerManager.isConnectedToHost) { oldValue, newValue in
if newValue, let host = selectedHost,
let eventName = multipeerManager.availableHosts[host] {
.onChange(of: p2pManager.isConnectedToHost) { oldValue, newValue in
if newValue, let host = selectedHost {
// Connected successfully
// Note: We do NOT call stopBrowsing() here anymore because it kills the session.
// The MultipeerManager handles the radio shutdown safely after stabilization.
// The P2PConnectionManager handles the radio shutdown safely after stabilization.
// Create a transcript file for the guest immediately
_ = try? fileManager.createTranscriptFile(eventName: eventName)
sessionState.joinGuestSession(eventName: eventName)
_ = try? fileManager.createTranscriptFile(eventName: host.eventName)
sessionState.joinGuestSession(eventName: host.eventName)
withAnimation {
sessionState.appPhase = .activeSession
}
}
}
}
private func joinHost(_ peerID: MCPeerID) {
selectedHost = peerID
private func joinHost(_ host: DiscoveredHost) {
selectedHost = host
isConnecting = true
connectionFailed = false
multipeerManager.joinHost(peerID)
p2pManager.joinHost(host)
}
private func setupConnectionFailedHandler() {
multipeerManager.onConnectionFailed = { [self] failedPeer in
p2pManager.onConnectionFailed = { [self] failedHost in
Task { @MainActor in
if failedPeer == selectedHost {
if failedHost.id == selectedHost?.id {
isConnecting = false
connectionFailed = true
}
@@ -227,7 +226,7 @@ struct HostRow: View {
#Preview {
GuestBrowserView(
multipeerManager: MultipeerManager(),
p2pManager: P2PConnectionManager(),
fileManager: FileStorageManager()
)
.environmentObject(SessionState())

View File

@@ -10,7 +10,7 @@ import SwiftUI
struct HostSetupView: View {
@EnvironmentObject var sessionState: SessionState
@ObservedObject var transcriptionManager: TranscriptionManager
@ObservedObject var multipeerManager: MultipeerManager
@ObservedObject var p2pManager: P2PConnectionManager
@ObservedObject var fileManager: FileStorageManager
@State private var eventName: String = ""
@@ -181,7 +181,7 @@ struct HostSetupView: View {
}
// Start hosting (advertising)
multipeerManager.startHosting(eventName: eventName)
p2pManager.startHosting(eventName: eventName)
// Start transcription
do {
@@ -207,7 +207,7 @@ struct HostSetupView: View {
#Preview {
HostSetupView(
transcriptionManager: TranscriptionManager(),
multipeerManager: MultipeerManager(),
p2pManager: P2PConnectionManager(),
fileManager: FileStorageManager()
)
.environmentObject(SessionState())

View File

@@ -11,7 +11,7 @@ import UIKit
struct SettingsView: View {
@EnvironmentObject var sessionState: SessionState
@ObservedObject var fileManager: FileStorageManager
@ObservedObject var multipeerManager: MultipeerManager
@ObservedObject var p2pManager: P2PConnectionManager
@ObservedObject var transcriptionManager: TranscriptionManager
@EnvironmentObject var settings: SettingsModel
@@ -50,7 +50,7 @@ struct SettingsView: View {
HStack {
Label("Listeners", systemImage: "person.2.fill")
Spacer()
Text("\(multipeerManager.connectedPeers.count)")
Text("\(p2pManager.connectedPeers.count)")
.foregroundColor(.secondary)
}
}
@@ -202,7 +202,7 @@ struct SettingsView: View {
}
// Disconnect networking
multipeerManager.disconnect()
p2pManager.disconnect()
// Clear session info
fileManager.clearSessionInfo()
@@ -232,7 +232,7 @@ struct ShareSheet: UIViewControllerRepresentable {
#Preview {
SettingsView(
fileManager: FileStorageManager(),
multipeerManager: MultipeerManager(),
p2pManager: P2PConnectionManager(),
transcriptionManager: TranscriptionManager()
)
.environmentObject(SessionState())

View File

@@ -10,7 +10,7 @@ import SwiftUI
struct TranscriptView: View {
@EnvironmentObject var sessionState: SessionState
@ObservedObject var transcriptionManager: TranscriptionManager
@ObservedObject var multipeerManager: MultipeerManager
@ObservedObject var p2pManager: P2PConnectionManager
@ObservedObject var fileManager: FileStorageManager
@ObservedObject var audioManager: AudioSessionManager
@@ -113,7 +113,7 @@ struct TranscriptView: View {
.sheet(isPresented: $sessionState.showSettings) {
SettingsView(
fileManager: fileManager,
multipeerManager: multipeerManager,
p2pManager: p2pManager,
transcriptionManager: transcriptionManager
)
}
@@ -168,17 +168,17 @@ struct TranscriptView: View {
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("\(multipeerManager.connectedPeers.count) listener\(multipeerManager.connectedPeers.count == 1 ? "" : "s") connected")
Text("\(p2pManager.connectedPeers.count) listener\(p2pManager.connectedPeers.count == 1 ? "" : "s") connected")
.font(.caption)
.foregroundColor(.secondary)
}
} else {
HStack(spacing: 4) {
Circle()
.fill(multipeerManager.isConnectedToHost ? Color.green : Color.red)
.fill(p2pManager.isConnectedToHost ? Color.green : Color.red)
.frame(width: 8, height: 8)
Text(multipeerManager.isConnectedToHost ? "Connected" : "Disconnected")
Text(p2pManager.isConnectedToHost ? "Connected" : "Disconnected")
.font(.caption)
.foregroundColor(.secondary)
}
@@ -352,7 +352,7 @@ struct TranscriptView: View {
}
// Disconnect networking
multipeerManager.disconnect()
p2pManager.disconnect()
// Clear session info
fileManager.clearSessionInfo()
@@ -369,8 +369,8 @@ struct TranscriptView: View {
Image(systemName: "timer")
.foregroundColor(settings.textColor)
let isTimerRunning = transcriptionManager.isTranscribing ||
(sessionState.userRole == .guest && multipeerManager.isConnectedToHost)
let isTimerRunning = transcriptionManager.isTranscribing ||
(sessionState.userRole == .guest && p2pManager.isConnectedToHost)
if isTimerRunning {
TimelineView(.periodic(from: .now, by: 1.0)) { context in
@@ -412,7 +412,7 @@ struct TranscriptView: View {
#Preview {
TranscriptView(
transcriptionManager: TranscriptionManager(),
multipeerManager: MultipeerManager(),
p2pManager: P2PConnectionManager(),
fileManager: FileStorageManager(),
audioManager: AudioSessionManager()
)

View File

@@ -0,0 +1,243 @@
# BeamScribe: MPC to Network.framework Transition Plan
## Overview
Transition from MultipeerConnectivity to Network.framework while preserving all existing functionality: live transcription broadcast, late-joiner history sync, alerts, and multi-guest support.
---
## Phase 1: Create P2PConnectionManager (Network.framework Core)
### 1.1 Define the Protocol Interface
Create a protocol that mirrors what `MultipeerManager` currently provides to the UI:
```swift
protocol P2PConnectionDelegate: AnyObject {
func didDiscoverHost(_ host: DiscoveredHost)
func didLoseHost(_ host: DiscoveredHost)
func didConnectGuest(_ guest: ConnectedPeer)
func didDisconnectGuest(_ guest: ConnectedPeer)
func didReceiveData(_ data: Data, from peer: ConnectedPeer)
func connectionStateChanged(_ state: P2PConnectionState)
}
```
### 1.2 Host Side: NWListener
Replace `MCNearbyServiceAdvertiser` with `NWListener`:
```swift
// Key configuration
let parameters = NWParameters.tcp
parameters.includePeerToPeer = true // Enables AWDL
parameters.serviceClass = .responsiveData // Low-latency priority
// Advertise with service type
let listener = try NWListener(using: parameters)
listener.service = NWListener.Service(
name: eventName, // Your event name (currently in discoveryInfo)
type: "_beamscribe._tcp"
)
```
**Connection Handling:**
- Store accepted connections in `[UUID: NWConnection]` dictionary
- Implement same 4-second "traffic gate" stabilization logic
- Track unstable connections (disconnect within 5 seconds)
### 1.3 Guest Side: NWBrowser + NWConnection
Replace `MCNearbyServiceBrowser` with `NWBrowser`:
```swift
let parameters = NWParameters.tcp
parameters.includePeerToPeer = true
let browser = NWBrowser(for: .bonjour(type: "_beamscribe._tcp", domain: nil), using: parameters)
```
**Discovery Results:**
- `NWBrowser.Result` includes the service name (your event name)
- Create `NWConnection` to connect to discovered host
- Implement retry logic (1s, 2s, 4s backoff - same as current)
---
## Phase 2: BLE Fast-Discovery Layer (CoreBluetooth)
### 2.1 Why BLE?
Standard Bonjour discovery over AWDL can take 2-8 seconds due to channel hopping. BLE advertisement is near-instant and "wakes" the AWDL interface.
### 2.2 BLEDiscoveryManager - Host (Peripheral)
```swift
let serviceUUID = CBUUID(string: "YOUR-BEAMSCRIBE-UUID")
// Advertise when hosting
peripheralManager.startAdvertising([
CBAdvertisementDataServiceUUIDsKey: [serviceUUID],
CBAdvertisementDataLocalNameKey: eventName.prefix(8) // BLE has 28-byte limit
])
```
### 2.3 BLEDiscoveryManager - Guest (Central)
```swift
// Scan for BeamScribe hosts
centralManager.scanForPeripherals(withServices: [serviceUUID])
// On discovery, trigger Network.framework browser
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, ...) {
delegate?.bleDidDiscoverHost(name: peripheral.name)
// Start NWBrowser immediately - AWDL is now primed
}
```
### 2.4 BLE Lifecycle
- Start BLE advertising when `startHosting()` called
- Start BLE scanning when `startBrowsing()` called
- Stop BLE once TCP connection established (saves battery)
- Resume BLE if connection lost (for reconnection)
---
## Phase 3: Data Transmission Layer
### 3.1 Framing Protocol
Network.framework uses raw TCP streams. Implement length-prefixed framing:
```swift
// Sending
func send(_ packet: TranscriptPacket, to connection: NWConnection) {
let data = try JSONEncoder().encode(packet)
var length = UInt32(data.count).bigEndian
let header = Data(bytes: &length, count: 4)
connection.send(content: header + data, completion: .contentProcessed { ... })
}
// Receiving
func receiveNextPacket(on connection: NWConnection) {
// Read 4-byte header first, then read that many bytes for payload
}
```
### 3.2 Broadcast to All Guests (Host)
Replace `MCSession.send(_:toPeers:with:)`:
```swift
func broadcastPacket(_ packet: TranscriptPacket) {
let data = encode(packet)
for (_, connection) in stableConnections {
connection.send(content: data, completion: ...)
}
}
```
### 3.3 Packet Types (Keep Existing)
Your current `TranscriptPacket` model works unchanged:
- `.fullHistory` - Late-joiner sync
- `.liveChunk` - Real-time transcription (partial/final)
- `.alert` - Host disconnected, battery low, session resumed
---
## Phase 4: Integration with Existing Code
### 4.1 File Changes Summary
| Current File | Changes |
|--------------|---------|
| `MultipeerManager.swift` | Deprecate, keep as fallback initially |
| `P2PConnectionManager.swift` | NEW - Main networking logic |
| `BLEDiscoveryManager.swift` | NEW - CoreBluetooth layer |
| `NetworkFraming.swift` | NEW - TCP framing utilities |
| `SessionState.swift` | Update peer tracking types |
| `ContentView.swift` | Swap manager reference |
| `GuestBrowserView.swift` | Use new discovery delegate |
| `Info.plist` | Add BLE background modes |
### 4.2 Info.plist Additions
```xml
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>BeamScribe uses Bluetooth to quickly discover nearby sessions.</string>
```
### 4.3 Fallback Strategy
Keep `MultipeerManager` available initially:
```swift
// In SessionState or AppConfig
var useNetworkFramework: Bool = true
var connectionManager: any P2PConnectionDelegate {
useNetworkFramework ? p2pManager : legacyMultipeerManager
}
```
---
## Phase 5: Testing & Verification
### 5.1 Terminal Checks
```bash
# Verify AWDL activates during connection
ifconfig awdl0
# Check for active peer-to-peer interface
netstat -rn | grep awdl
```
### 5.2 Latency Testing
```swift
// Add to packet for round-trip measurement
struct TranscriptPacket {
// existing fields...
var sentTimestamp: TimeInterval?
}
```
### 5.3 Test Scenarios
- [ ] Host starts, 1 guest joins
- [ ] Host starts, 7 guests join simultaneously
- [ ] Guest joins late, receives full history
- [ ] Host disconnects, guests receive alert
- [ ] App backgrounded, BLE keeps discovery alive
- [ ] Same Wi-Fi network (should prefer infrastructure)
- [ ] Different Wi-Fi / no Wi-Fi (should use AWDL)
---
## Implementation Order
1. **P2PConnectionManager** - NWListener + NWBrowser (no BLE yet)
2. **NetworkFraming** - Length-prefixed TCP framing
3. **Integration** - Wire up to ContentView/GuestBrowserView
4. **Testing** - Verify feature parity with MPC
5. **BLEDiscoveryManager** - Add fast-discovery layer
6. **Optimization** - Infrastructure vs AWDL preference logic
7. **Cleanup** - Remove MultipeerManager fallback
---
## Estimated New Files
```
BeamScribe/
├── Managers/
│ ├── MultipeerManager.swift (existing - deprecate later)
│ ├── P2PConnectionManager.swift (NEW - ~400 lines)
│ ├── BLEDiscoveryManager.swift (NEW - ~150 lines)
│ └── NetworkFraming.swift (NEW - ~80 lines)
├── Models/
│ ├── DiscoveredHost.swift (NEW - ~20 lines)
│ └── ConnectedPeer.swift (NEW - ~20 lines)
```
---
## Rollback Plan
If issues arise:
```bash
git reset --hard backup-before-networkframework
```

11
how-to-revert-git Normal file
View File

@@ -0,0 +1,11 @@
Tag before major changes:
git tag -a backup-before-networkframework -m "Known good state using MPC"
If you need to revert later:
git reset --hard backup-before-networkframework
List tags with their messages:
git tag -n
git tag -n
⎿ backup-before-networkframework Known good state using MPC

1257
transition.md Normal file

File diff suppressed because it is too large Load Diff