Compare commits
2 Commits
backup-bef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c1e3d6fff | |||
| ad2b451cb1 |
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(./check_build.sh:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,6 @@ 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
|
||||
|
||||
@@ -21,9 +18,6 @@ struct BeamScribeApp: App {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(sessionState)
|
||||
.environmentObject(transcriptionManager)
|
||||
.environmentObject(multipeerManager)
|
||||
.environmentObject(fileManager)
|
||||
.environmentObject(subscriptionManager)
|
||||
// Enforce light mode for now to ensure consistency
|
||||
.preferredColorScheme(.light)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -80,7 +80,7 @@ struct ContentView: View {
|
||||
|
||||
// Broadcast partial result to guests (skip if solo mode)
|
||||
if !sessionState.isSoloMode {
|
||||
multipeerManager.sendLiveChunk(
|
||||
p2pManager.sendLiveChunk(
|
||||
text: text,
|
||||
eventName: sessionState.eventName,
|
||||
isFinal: false
|
||||
@@ -101,7 +101,7 @@ struct ContentView: View {
|
||||
|
||||
// Broadcast to guests (skip if solo mode)
|
||||
if !sessionState.isSoloMode {
|
||||
multipeerManager.sendLiveChunk(
|
||||
p2pManager.sendLiveChunk(
|
||||
text: text,
|
||||
eventName: sessionState.eventName,
|
||||
isFinal: true
|
||||
@@ -118,7 +118,7 @@ struct ContentView: View {
|
||||
|
||||
// Broadcast resume marker to guests (skip if solo mode)
|
||||
if !sessionState.isSoloMode {
|
||||
multipeerManager.sendAlert(
|
||||
p2pManager.sendAlert(
|
||||
type: .sessionResumed,
|
||||
eventName: sessionState.eventName
|
||||
)
|
||||
@@ -132,7 +132,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -147,8 +147,6 @@ struct ContentView: View {
|
||||
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)
|
||||
}
|
||||
@@ -183,7 +181,7 @@ 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
|
||||
@@ -195,8 +193,21 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -205,15 +216,15 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
// 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.")
|
||||
print("New peer joined: \(peerID). Sending full history.")
|
||||
|
||||
multipeerManager.sendFullHistory(
|
||||
p2pManager.sendFullHistory(
|
||||
to: peerID,
|
||||
text: sessionState.fullTranscriptText,
|
||||
eventName: sessionState.eventName,
|
||||
|
||||
@@ -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>
|
||||
|
||||
211
BeamScribe/Managers/BLEDiscoveryManager.swift
Normal file
211
BeamScribe/Managers/BLEDiscoveryManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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] {
|
||||
|
||||
159
BeamScribe/Managers/NetworkFraming.swift
Normal file
159
BeamScribe/Managers/NetworkFraming.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
663
BeamScribe/Managers/P2PConnectionManager.swift
Normal file
663
BeamScribe/Managers/P2PConnectionManager.swift
Normal 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 = ""
|
||||
}
|
||||
}
|
||||
47
BeamScribe/Models/ConnectedPeer.swift
Normal file
47
BeamScribe/Models/ConnectedPeer.swift
Normal 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)
|
||||
}
|
||||
38
BeamScribe/Models/DiscoveredHost.swift
Normal file
38
BeamScribe/Models/DiscoveredHost.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,7 +61,7 @@ struct GuestBrowserView: View {
|
||||
}
|
||||
|
||||
// Host List
|
||||
if multipeerManager.availableHosts.isEmpty {
|
||||
if p2pManager.availableHosts.isEmpty {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
@@ -82,14 +82,14 @@ struct GuestBrowserView: View {
|
||||
} 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,25 +116,24 @@ 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)
|
||||
_ = try? fileManager.createTranscriptFile(eventName: host.eventName)
|
||||
|
||||
sessionState.joinGuestSession(eventName: eventName)
|
||||
sessionState.joinGuestSession(eventName: host.eventName)
|
||||
|
||||
withAnimation {
|
||||
sessionState.appPhase = .activeSession
|
||||
@@ -143,18 +142,18 @@ struct GuestBrowserView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
@@ -370,7 +370,7 @@ struct TranscriptView: View {
|
||||
.foregroundColor(settings.textColor)
|
||||
|
||||
let isTimerRunning = transcriptionManager.isTranscribing ||
|
||||
(sessionState.userRole == .guest && multipeerManager.isConnectedToHost)
|
||||
(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()
|
||||
)
|
||||
|
||||
243
IMPLEMENTATION_PLAN_NETWORK_FRAMEWORK.md
Normal file
243
IMPLEMENTATION_PLAN_NETWORK_FRAMEWORK.md
Normal 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
11
how-to-revert-git
Normal 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
1257
transition.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user