3 Commits

10 changed files with 1957 additions and 1491 deletions

View File

@@ -272,7 +272,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.AtTable;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -314,7 +314,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.3;
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.AtTable;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;

View File

@@ -15,7 +15,7 @@ struct AtTableApp: App {
@AppStorage("userColorHex") var userColorHex: String = "#00008B"
// APP-LEVEL SESSION: Ensures stable identity (Instance ID) across view reloads
@StateObject var multipeerSession = MultipeerSession()
@StateObject var meshManager = MeshNetworkManager()
init() {
// Reset navigation state on launch so users always see the "Login" screen first.
@@ -41,7 +41,7 @@ struct AtTableApp: App {
)
}
}
.environmentObject(multipeerSession)
.environmentObject(meshManager)
}
}
}

View File

@@ -1,9 +1,8 @@
import SwiftUI
import MultipeerConnectivity
import Combine
struct ChatView: View {
@EnvironmentObject var multipeerSession: MultipeerSession
@EnvironmentObject var meshManager: MeshNetworkManager
@StateObject var speechRecognizer = SpeechRecognizer()
@ObservedObject var networkMonitor = NetworkMonitor.shared
@@ -30,7 +29,7 @@ struct ChatView: View {
HStack {
Button(action: {
speechRecognizer.stopRecording()
multipeerSession.stop()
meshManager.stop()
isOnboardingComplete = false
}) {
HStack(spacing: 6) {
@@ -53,7 +52,7 @@ struct ChatView: View {
.tracking(2)
.foregroundColor(.white.opacity(0.6))
Text("\(multipeerSession.connectedPeers.count) Active")
Text("\(meshManager.connectedPeerUsers.count) Active")
.font(.caption2)
.fontWeight(.bold)
.foregroundColor(.green)
@@ -68,7 +67,7 @@ struct ChatView: View {
.padding(.bottom, 10)
// 3. Peer Status / Live Transcriptions
if multipeerSession.connectedPeers.isEmpty {
if meshManager.connectedPeerUsers.isEmpty {
VStack {
Spacer()
@@ -100,11 +99,11 @@ struct ChatView: View {
}
} else {
// Connected Peers Row (Small Pills)
if !multipeerSession.connectedPeerUsers.isEmpty {
if !meshManager.connectedPeerUsers.isEmpty {
HStack(spacing: 8) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(multipeerSession.connectedPeerUsers, id: \.self) { peer in
ForEach(meshManager.connectedPeerUsers, id: \.self) { peer in
HStack(spacing: 6) {
Circle()
.fill(Color(hex: peer.colorHex))
@@ -124,7 +123,7 @@ struct ChatView: View {
}
// Full Mesh Indicator
if multipeerSession.isAtCapacity {
if meshManager.isAtCapacity {
Text("FULL")
.font(.system(size: 10, weight: .black, design: .monospaced))
.foregroundColor(.orange)
@@ -142,10 +141,10 @@ struct ChatView: View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(multipeerSession.receivedMessages) { message in
ForEach(meshManager.receivedMessages) { message in
MessageBubble(
message: message,
isMyMessage: message.senderNodeID == multipeerSession.myNodeIDPublic
isMyMessage: message.senderNodeID == meshManager.myNodeIDPublic
)
.id(message.id)
.transition(.opacity.combined(with: .scale(scale: 0.95)))
@@ -154,25 +153,25 @@ struct ChatView: View {
.padding(.bottom, 20)
}
.scrollIndicators(.hidden)
.onChange(of: multipeerSession.receivedMessages.count) {
if let lastId = multipeerSession.receivedMessages.last?.id {
.onChange(of: meshManager.receivedMessages.count) {
if let lastId = meshManager.receivedMessages.last?.id {
withAnimation {
proxy.scrollTo(lastId, anchor: .bottom)
}
}
if userRole == .deaf {
if let lastMsg = multipeerSession.receivedMessages.last, lastMsg.senderNodeID != multipeerSession.myNodeIDPublic {
if let lastMsg = meshManager.receivedMessages.last, lastMsg.senderNodeID != meshManager.myNodeIDPublic {
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.success)
}
}
}
// Auto-scroll when transcription card appears to prevent blocking
.onChange(of: multipeerSession.liveTranscripts.isEmpty) {
if !multipeerSession.liveTranscripts.isEmpty {
.onChange(of: meshManager.liveTranscripts.isEmpty) {
if !meshManager.liveTranscripts.isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if let lastId = multipeerSession.receivedMessages.last?.id {
if let lastId = meshManager.receivedMessages.last?.id {
withAnimation {
proxy.scrollTo(lastId, anchor: .bottom)
}
@@ -185,11 +184,11 @@ struct ChatView: View {
// Live Transcription Cards (Moved to Bottom)
// Live Transcription Cards (Moved to Bottom, Stacked Vertically)
if userRole == .deaf && !multipeerSession.liveTranscripts.isEmpty {
if userRole == .deaf && !meshManager.liveTranscripts.isEmpty {
VStack(spacing: 8) {
ForEach(multipeerSession.liveTranscripts.sorted(by: { $0.key < $1.key }), id: \.key) { nodeIDKey, text in
ForEach(meshManager.liveTranscripts.sorted(by: { $0.key < $1.key }), id: \.key) { nodeIDKey, text in
// Look up friendly name from connectedPeerUsers by nodeID
let displayName = multipeerSession.connectedPeerUsers.first(where: { $0.nodeID == nodeIDKey })?.name ?? nodeIDKey
let displayName = meshManager.connectedPeerUsers.first(where: { $0.nodeID == nodeIDKey })?.name ?? nodeIDKey
VStack(alignment: .leading, spacing: 6) {
Text(displayName)
.font(.caption2)
@@ -242,11 +241,11 @@ struct ChatView: View {
.ignoresSafeArea(.container, edges: .all)
.onAppear {
UIApplication.shared.isIdleTimerDisabled = true
multipeerSession.start()
meshManager.start()
// Re-apply identity if needed (though App state should handle it)
if multipeerSession.userName != userName {
multipeerSession.setIdentity(name: userName, colorHex: userColorHex, role: userRole)
if meshManager.userName != userName {
meshManager.setIdentity(name: userName, colorHex: userColorHex, role: userRole)
}
// Configure Speech Recognizer callback
@@ -254,8 +253,8 @@ struct ChatView: View {
let message = MeshMessage(
id: UUID(),
senderID: userName,
senderNodeID: multipeerSession.myNodeIDPublic,
senderInstance: multipeerSession.myInstancePublic,
senderNodeID: meshManager.myNodeIDPublic,
senderInstance: meshManager.myInstancePublic,
senderRole: userRole,
senderColorHex: userColorHex,
content: resultText,
@@ -263,7 +262,7 @@ struct ChatView: View {
isPartial: false,
timestamp: Date()
)
multipeerSession.send(message: message)
meshManager.send(message: message)
}
// Configure Partial Result callback (No Throttling)
@@ -271,8 +270,8 @@ struct ChatView: View {
let message = MeshMessage(
id: UUID(),
senderID: userName,
senderNodeID: multipeerSession.myNodeIDPublic, // Added NodeID for consistent transcript keying
senderInstance: multipeerSession.myInstancePublic,
senderNodeID: meshManager.myNodeIDPublic, // Added NodeID for consistent transcript keying
senderInstance: meshManager.myInstancePublic,
senderRole: userRole,
senderColorHex: userColorHex,
content: partialText,
@@ -280,7 +279,7 @@ struct ChatView: View {
isPartial: true,
timestamp: Date()
)
multipeerSession.send(message: message)
meshManager.send(message: message)
}
// Auto-start recording for Hearing users
@@ -299,8 +298,8 @@ struct ChatView: View {
let message = MeshMessage(
id: UUID(),
senderID: userName,
senderNodeID: multipeerSession.myNodeIDPublic, // Added NodeID
senderInstance: multipeerSession.myInstancePublic,
senderNodeID: meshManager.myNodeIDPublic, // Added NodeID
senderInstance: meshManager.myInstancePublic,
senderRole: userRole,
senderColorHex: userColorHex,
content: messageText,
@@ -309,7 +308,7 @@ struct ChatView: View {
timestamp: Date()
)
multipeerSession.send(message: message)
meshManager.send(message: message)
messageText = ""
}
@@ -320,4 +319,4 @@ struct ChatView: View {
speechRecognizer.startRecording()
}
}
}
}

View File

@@ -0,0 +1,567 @@
import Foundation
import Network
import Combine
import SwiftUI
class MeshNetworkManager: NSObject, ObservableObject {
// MARK: - Published State
@Published var connectedPeerUsers: [PeerUser] = []
@Published var receivedMessages: [MeshMessage] = []
@Published var liveTranscripts: [String: String] = [:] // Keyed by senderNodeID
// MARK: - Internal Types
private struct TransportMessage: Codable {
enum MessageType: Codable {
// Added instance to identification for ghost filtering
case identification(nodeID: String, instance: Int, name: String, role: UserRole, color: String)
case broadcast(payload: Data)
}
let type: MessageType
let senderNodeID: String
}
// MARK: - Properties
private let serviceType = "_access-mesh._tcp" // Must match Constants.serviceType (implied)
private let myNodeID: String
private var myInstance: Int
// UI Compatibility Accessors
var myNodeIDPublic: String { myNodeID }
var myInstancePublic: Int { myInstance }
var isAtCapacity: Bool { connectedPeerUsers.count >= 7 }
// User Identity
private var myUserName: String = UIDevice.current.name
var userName: String { myUserName }
private var myUserColor: String = "#3357FF"
private var myUserRole: UserRole = .hearing
// Network Objects
private var listener: NWListener?
private var browser: NWBrowser?
// Connection State
// nodeID -> NWConnection
private var connections: [String: NWConnection] = [:]
// Track instances for ghost filtering
private var peerInstances: [String: Int] = [:]
// Stable peers (Traffic Gated)
private var stableNodeIDs: Set<String> = []
// Pending Connections (to avoid duplicates)
private var pendingConnections: Set<String> = []
// Store endpoints for retry
private var knownEndpoints: [String: NWEndpoint] = [:]
// Internal Peer Map (before they become "connectedPeerUsers" or while unstable)
// nodeID -> PeerUser
private var internalPeerMap: [String: PeerUser] = [:]
private var isRunning = false
private lazy var log = Logger(nameProvider: { [weak self] in
self?.myUserName ?? UIDevice.current.name
})
// MARK: - Initialization
override init() {
self.myNodeID = NodeIdentity.nodeID
self.myInstance = NodeIdentity.nextInstance()
super.init()
}
// MARK: - Public API
func setIdentity(name: String, colorHex: String, role: UserRole) {
self.myUserName = name
self.myUserColor = colorHex
self.myUserRole = role
}
func start() {
guard !isRunning else { return }
isRunning = true
// Ensure fresh instance on every start
self.myInstance = NodeIdentity.nextInstance()
startListener()
startBrowser()
log.info("MeshNetworkManager started. NodeID: \(self.myNodeID), Instance: \(self.myInstance)")
}
func stop() {
isRunning = false
listener?.cancel()
listener = nil
browser?.cancel()
browser = nil
for (_, conn) in connections {
conn.cancel()
}
connections.removeAll()
pendingConnections.removeAll()
stableNodeIDs.removeAll()
internalPeerMap.removeAll()
peerInstances.removeAll()
DispatchQueue.main.async {
self.connectedPeerUsers.removeAll()
self.receivedMessages.removeAll()
self.liveTranscripts.removeAll()
}
log.info("MeshNetworkManager stopped.")
}
func send(message: MeshMessage) {
// Encode the app-level MeshMessage
guard let payload = try? JSONEncoder().encode(message) else { return }
// Wrap in TransportMessage
let transportMsg = TransportMessage(type: .broadcast(payload: payload), senderNodeID: myNodeID)
guard let data = try? JSONEncoder().encode(transportMsg) else { return }
// Broadcast to all STABLE connections
for (nodeID, connection) in connections where stableNodeIDs.contains(nodeID) {
NetworkFraming.send(data, over: connection) { [weak self] error in
if let error = error {
self?.log.error("Send failed to \(nodeID): \(error)")
}
}
}
// Handle local display of the message
DispatchQueue.main.async {
if message.isPartial {
// Don't show own partials usually, or handle in UI
} else if !message.isHandshake && !message.isKeepAlive {
self.receivedMessages.append(message)
}
}
}
// MARK: - Network Parameters
private func createParameters() -> NWParameters {
let parameters = NWParameters.tcp
parameters.includePeerToPeer = true // Enables AWDL
// Enable KeepAlive to detect broken links
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveInterval = 10
parameters.defaultProtocolStack.transportProtocol = tcpOptions
return parameters
}
// MARK: - Listener (Host)
private func startListener() {
do {
let parameters = createParameters()
listener = try NWListener(using: parameters)
// Advertise NodeID and Instance in TXT record
let txtRecord = "nodeID=\(myNodeID)\tinstance=\(myInstance)"
var txtData = Data()
for entry in txtRecord.split(separator: "\t") {
let s = String(entry)
guard let d = s.data(using: .utf8), d.count < 255 else { continue }
txtData.append(UInt8(d.count))
txtData.append(d)
}
listener?.service = NWListener.Service(
name: myNodeID, // Service Name = NodeID
type: serviceType,
txtRecord: txtData
)
listener?.newConnectionHandler = { [weak self] connection in
self?.handleIncomingConnection(connection)
}
listener?.stateUpdateHandler = { [weak self] state in
self?.log.info("Listener State: \(state)")
}
listener?.start(queue: .main)
} catch {
log.error("Failed to create listener: \(error)")
}
}
private func handleIncomingConnection(_ connection: NWConnection) {
// Add state handler to detect disconnections
// Note: We don't know the peerNodeID yet until we receive identification
// So we'll add a generic handler that looks up the connection
connection.stateUpdateHandler = { [weak self] state in
guard let self = self else { return }
switch state {
case .failed(let error):
self.log.error("Incoming connection failed: \(error)")
self.handleIncomingConnectionClosed(connection)
case .cancelled:
self.log.info("Incoming connection cancelled")
self.handleIncomingConnectionClosed(connection)
default:
break
}
}
connection.start(queue: .main)
// Start receiving immediately to get the Identification message
NetworkFraming.startReceiving(from: connection, onData: { [weak self] data in
self?.handleData(data, from: connection)
}, onError: { [weak self] error in
self?.log.error("Receive error on incoming connection: \(error)")
self?.handleIncomingConnectionClosed(connection)
})
}
/// Clean up an incoming connection by finding which peer it belongs to
private func handleIncomingConnectionClosed(_ connection: NWConnection) {
connection.cancel()
// Find which peer this connection belongs to
for (nodeID, conn) in connections {
if conn === connection {
log.info("Cleaning up connection for peer: \(nodeID)")
connections.removeValue(forKey: nodeID)
pendingConnections.remove(nodeID)
stableNodeIDs.remove(nodeID)
internalPeerMap.removeValue(forKey: nodeID)
updatePublicPeerList()
break
}
}
}
// MARK: - Browser (Client)
private func startBrowser() {
let parameters = createParameters()
browser = NWBrowser(for: .bonjour(type: serviceType, domain: nil), using: parameters)
browser?.stateUpdateHandler = { [weak self] state in
self?.log.info("Browser state: \(state)")
}
browser?.browseResultsChangedHandler = { [weak self] results, changes in
self?.handleBrowseResults(results, changes: changes)
}
browser?.start(queue: .main)
}
private func handleBrowseResults(_ results: Set<NWBrowser.Result>, changes: Set<NWBrowser.Result.Change>) {
for change in changes {
switch change {
case .added(let result):
handleDiscovered(result)
case .removed(let result):
handleLost(result)
case .changed(old: _, new: let newResult, flags: _):
// When a peer rejoins, the browser may report this as a "changed" event
// (same service name but updated TXT metadata with new instance).
// Treat it as a new discovery to check instance and reconnect if needed.
handleDiscovered(newResult)
default:
break
}
}
}
private func handleDiscovered(_ result: NWBrowser.Result) {
guard case .service(let name, _, _, _) = result.endpoint else { return }
var peerNodeID = name // Default to service name
var peerInstance = 0
if case .bonjour(let txt) = result.metadata {
if let nid = txt["nodeID"] { peerNodeID = nid }
if let instStr = txt["instance"], let inst = Int(instStr) { peerInstance = inst }
}
guard peerNodeID != myNodeID else { return }
// Check if we need to handle reconnection
// NOTE: TXT record often isn't available in browse results (peerInstance=0)
// so we can't reliably ghost-filter here. Let handleIdentification do that.
if let existingInst = peerInstances[peerNodeID] {
// If discovered instance is 0, TXT record wasn't available
// DON'T close existing connections - they might be valid!
if peerInstance == 0 {
// Keep existing connections, allow new connections only if none exist
} else if peerInstance > existingInst {
// We have a valid newer instance from TXT - this is reliable, reconnect
if let conn = connections[peerNodeID] {
conn.cancel()
connections.removeValue(forKey: peerNodeID)
pendingConnections.remove(peerNodeID)
stableNodeIDs.remove(peerNodeID)
internalPeerMap.removeValue(forKey: peerNodeID)
}
peerInstances[peerNodeID] = peerInstance
} else if peerInstance < existingInst && peerInstance != 0 {
// Valid older instance - reject (ghost)
return
}
}
// Store endpoint for potential retry
knownEndpoints[peerNodeID] = result.endpoint
// Check if we already have a connection or one is pending
if connections[peerNodeID] != nil || pendingConnections.contains(peerNodeID) {
return
}
// Determine if we should initiate connection using tie-breaker
// Even when reconnecting, respect the tie-breaker to avoid both sides connecting
let shouldInitiate = myNodeID < peerNodeID
if shouldInitiate {
initiateConnection(to: result.endpoint, peerNodeID: peerNodeID)
}
}
private func handleLost(_ result: NWBrowser.Result) {
guard case .service(let name, _, _, _) = result.endpoint else { return }
// The service name is the nodeID - clean up connection if exists
let peerNodeID = name
if let conn = connections[peerNodeID] {
conn.cancel()
connections.removeValue(forKey: peerNodeID)
pendingConnections.remove(peerNodeID)
stableNodeIDs.remove(peerNodeID)
internalPeerMap.removeValue(forKey: peerNodeID)
// Note: Don't remove from peerInstances - we need it for ghost filtering on reconnect
updatePublicPeerList()
}
}
// MARK: - Connection Management
private func initiateConnection(to endpoint: NWEndpoint, peerNodeID: String) {
guard connections[peerNodeID] == nil else { return }
guard !pendingConnections.contains(peerNodeID) else { return }
pendingConnections.insert(peerNodeID)
let parameters = createParameters()
let connection = NWConnection(to: endpoint, using: parameters)
connection.stateUpdateHandler = { [weak self] state in
self?.handleConnectionState(state, peerNodeID: peerNodeID, connection: connection)
}
connection.start(queue: .main)
connections[peerNodeID] = connection
// Add timeout for connection - if not ready within 5 seconds, cancel and retry
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
guard let self = self else { return }
// Check if connection is still pending (not yet stabilized)
if self.pendingConnections.contains(peerNodeID) && !self.stableNodeIDs.contains(peerNodeID) {
if let conn = self.connections[peerNodeID] {
conn.cancel()
}
self.connections.removeValue(forKey: peerNodeID)
self.pendingConnections.remove(peerNodeID)
// Don't remove from peerInstances - keep for ghost filtering
// Schedule a retry after 1 second if we still have the endpoint
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
guard let self = self, self.isRunning else { return }
// Only retry if we still don't have a connection and we should initiate
if self.connections[peerNodeID] == nil &&
!self.pendingConnections.contains(peerNodeID) &&
!self.stableNodeIDs.contains(peerNodeID) {
if let endpoint = self.knownEndpoints[peerNodeID] {
// Check tie-breaker
if self.myNodeID < peerNodeID {
self.initiateConnection(to: endpoint, peerNodeID: peerNodeID)
}
}
}
}
}
}
NetworkFraming.startReceiving(from: connection, onData: { [weak self] data in
self?.handleData(data, from: connection)
}, onError: { [weak self] error in
self?.handleConnectionError(peerNodeID: peerNodeID, error: error)
})
}
private func handleConnectionState(_ state: NWConnection.State, peerNodeID: String, connection: NWConnection) {
switch state {
case .ready:
log.info("Connection ready: \(peerNodeID). Sending Identity.")
sendIdentity(to: connection)
pendingConnections.remove(peerNodeID)
case .failed(let error):
log.error("Connection failed \(peerNodeID): \(error)")
handleConnectionError(peerNodeID: peerNodeID, error: error)
case .cancelled:
handleConnectionError(peerNodeID: peerNodeID, error: nil)
default: break
}
}
private func handleConnectionError(peerNodeID: String, error: Error?) {
connections[peerNodeID]?.cancel()
connections.removeValue(forKey: peerNodeID)
pendingConnections.remove(peerNodeID)
stableNodeIDs.remove(peerNodeID)
internalPeerMap.removeValue(forKey: peerNodeID)
updatePublicPeerList()
}
// MARK: - Data Handling & Logic
private func sendIdentity(to connection: NWConnection) {
let msg = TransportMessage(
type: .identification(nodeID: myNodeID, instance: myInstance, name: myUserName, role: myUserRole, color: myUserColor),
senderNodeID: myNodeID
)
guard let data = try? JSONEncoder().encode(msg) else { return }
NetworkFraming.send(data, over: connection) { [weak self] error in
if let error = error {
self?.log.error("Failed to send identity: \(error)")
}
}
}
private func handleData(_ data: Data, from connection: NWConnection) {
guard let msg = try? JSONDecoder().decode(TransportMessage.self, from: data) else { return }
switch msg.type {
case .identification(let nodeID, let instance, let name, let role, let color):
handleIdentification(nodeID: nodeID, instance: instance, name: name, role: role, color: color, connection: connection)
case .broadcast(let payload):
handleBroadcast(payload, from: msg.senderNodeID)
}
}
private func handleIdentification(nodeID: String, instance: Int, name: String, role: UserRole, color: String, connection: NWConnection) {
log.info("Identified peer: \(nodeID) (inst: \(instance)) - \(name)")
// GHOST CHECK
if let existingInst = peerInstances[nodeID] {
if instance < existingInst {
log.info("Rejecting identification from OLDER instance \(instance) < \(existingInst)")
connection.cancel()
return
} else if instance > existingInst {
log.info("Upgrading to NEWER instance \(instance) > \(existingInst)")
// Old connection should be cleaned up by overwrite
}
}
peerInstances[nodeID] = instance
// Store Connection if this was an incoming one (where we didn't know the ID yet)
if connections[nodeID] == nil {
connections[nodeID] = connection
// We accepted incoming, so we must send our identity back if we haven't
sendIdentity(to: connection)
} else if connections[nodeID] !== connection {
// Race condition: We have two connections for same NodeID.
// Tie-breaker logic should prevent this, but if it happens:
// "Lower ID initiates".
// If I am Lower, I should have initiated. The one I initiated should stay.
// If I am Higher, they should have initiated. This one (incoming) should stay.
// However, usually we overwrite with the newest authenticated one.
connections[nodeID]?.cancel() // Cancel old one
connections[nodeID] = connection
}
// Create PeerUser
let peer = PeerUser(nodeID: nodeID, name: name, colorHex: color, role: role, isStable: false)
internalPeerMap[nodeID] = peer
// Traffic Gate / Stabilization
Task { @MainActor in
let delay: UInt64 = 4_000_000_000
try? await Task.sleep(nanoseconds: delay)
// Verify connection still exists and matches this nodeID
guard connections[nodeID] != nil else { return }
stableNodeIDs.insert(nodeID)
if var p = internalPeerMap[nodeID] {
p.isStable = true
internalPeerMap[nodeID] = p
}
updatePublicPeerList()
log.info("Peer stabilized: \(name)")
}
}
private func handleBroadcast(_ payload: Data, from senderNodeID: String) {
guard let message = try? JSONDecoder().decode(MeshMessage.self, from: payload) else { return }
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if message.isPartial {
if message.content.isEmpty {
self.liveTranscripts.removeValue(forKey: senderNodeID)
} else {
self.liveTranscripts[senderNodeID] = message.content
}
} else if message.isKeepAlive {
// Ignore
} else if message.isHandshake {
// Handshake logic is handled by TransportMessage.identification now
} else {
self.receivedMessages.append(message)
self.liveTranscripts.removeValue(forKey: senderNodeID)
}
}
}
private func updatePublicPeerList() {
DispatchQueue.main.async {
self.connectedPeerUsers = self.internalPeerMap
.filter { self.stableNodeIDs.contains($0.key) }
.map { $0.value }
}
}
// MARK: - Logger Helper
struct Logger {
let nameProvider: () -> String
init(nameProvider: @escaping () -> String = { UIDevice.current.name }) {
self.nameProvider = nameProvider
}
func info(_ msg: String) { print("🕸️ [\(nameProvider())] \(msg)") }
func error(_ msg: String) { print("🕸️ ❌ [\(nameProvider())] \(msg)") }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
import Foundation
import Network
enum NetworkFraming {
static let headerSize = 4 // UInt32 for length
static let maxMessageSize: UInt32 = 10_485_760 // 10 MB
/// Frame data with 4-byte 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 framed data over connection
static func send(_ data: Data, over connection: NWConnection, completion: @escaping (Error?) -> Void) {
let framedData = frameData(data)
connection.send(content: framedData, completion: .contentProcessed { error in
completion(error)
})
}
/// Receive framed data from connection
static func receive(from connection: NWConnection, completion: @escaping (Result<Data, Error>) -> Void) {
// Read 4-byte 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 {
completion(.failure(FramingError.incompleteHeader))
return
}
let length = headerData.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }
guard length > 0, length <= maxMessageSize else {
completion(.failure(FramingError.invalidMessageSize(length)))
return
}
// Read payload
connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { payloadData, _, _, error in
if let error = error {
completion(.failure(error))
return
}
guard let payloadData = payloadData else {
completion(.failure(FramingError.incompletePayload))
return
}
completion(.success(payloadData))
}
}
}
/// Continuous receive loop
static func startReceiving(from connection: NWConnection, onData: @escaping (Data) -> Void, onError: @escaping (Error) -> Void) {
receive(from: connection) { result in
switch result {
case .success(let data):
onData(data)
startReceiving(from: connection, onData: onData, onError: onError)
case .failure(let error):
onError(error)
}
}
}
}
enum FramingError: LocalizedError {
case incompleteHeader
case incompletePayload
case invalidMessageSize(UInt32)
var errorDescription: String? {
switch self {
case .incompleteHeader: return "Incomplete header"
case .incompletePayload: return "Incomplete payload"
case .invalidMessageSize(let size): return "Invalid size: \(size)"
}
}
}

View File

@@ -1,18 +1,19 @@
import Foundation
import MultipeerConnectivity
struct PeerUser: Identifiable, Hashable {
let id: MCPeerID
let nodeID: String // Stable identifier from handshake (for reliable identity)
let id: String // Use nodeID as the primary stable ID
let nodeID: String
let name: String
let colorHex: String
let role: UserRole // Role from handshake (for data integrity)
let role: UserRole
var isStable: Bool = false // New: visual indicator for traffic gate status
init(peerID: MCPeerID, nodeID: String = "", name: String? = nil, colorHex: String = "#808080", role: UserRole = .hearing) {
self.id = peerID
init(nodeID: String, name: String, colorHex: String = "#808080", role: UserRole = .hearing, isStable: Bool = false) {
self.id = nodeID
self.nodeID = nodeID
self.name = name ?? peerID.displayName
self.name = name
self.colorHex = colorHex
self.role = role
self.isStable = isStable
}
}
}

View File

@@ -1,255 +0,0 @@
# Multipeer Connectivity (MPC) Architecture
This document explains how AtTable uses Apple's Multipeer Connectivity framework to create a peer-to-peer mesh network for real-time communication between deaf and hearing users.
---
## Overview
AtTable uses **Multipeer Connectivity (MPC)** to establish direct device-to-device connections without requiring a central server. The app supports connections over:
- **Wi-Fi** (same network)
- **Peer-to-peer Wi-Fi** (AWDL - Apple Wireless Direct Link)
- **Bluetooth**
When devices aren't on the same Wi-Fi network (e.g., on 5G/cellular), MPC automatically falls back to **AWDL** for peer-to-peer discovery and data transfer.
---
## User Onboarding Flow
### 1. Initial Setup (`OnboardingView.swift`)
When a user launches the app:
1. They enter their **name**
2. Select their **role** (Deaf or Hearing)
3. Choose an **aura color** (for visual identity in the mesh)
4. Tap **"Start Conversation"** to enter the mesh
```
User launches app → OnboardingView → Enter details → ChatView (mesh starts)
```
### 2. Identity Generation (`NodeIdentity.swift`)
Upon first launch, the app generates a **stable Node Identity**:
- **`nodeID`**: A UUID persisted in UserDefaults (stable per app installation)
- **`instance`**: A monotonic counter that increments each time a session starts
This identity system allows the mesh to:
- Reliably identify users across reconnections
- Detect and filter "ghost" peers (stale connections from previous sessions)
- Handle device reboots gracefully
---
## Network Connection Process
### Discovery & Connection (`MultipeerSession.swift`)
When `ChatView` appears, it calls `multipeerSession.start()`, which:
1. **Sets up the MCSession** with encryption disabled (for faster AWDL connections)
2. **Starts browsing** for nearby peers using `MCNearbyServiceBrowser`
3. **Starts advertising** (after 0.5s delay) using `MCNearbyServiceAdvertiser`
### Wi-Fi vs Cellular/5G Connections
| Network Type | Connection Method | Handshake Delay | Connection Time |
|--------------|-------------------|-----------------|-----------------|
| **Wi-Fi (same network)** | Infrastructure Wi-Fi | 0.5 seconds | Near-instant |
| **Cellular/5G** | AWDL (peer-to-peer Wi-Fi) | 1.5 seconds | Up to 60 seconds |
The app uses `NetworkMonitor.swift` to detect the current network type and adjusts timing:
```swift
let isWiFi = NetworkMonitor.shared.isWiFi
let delay = isWiFi ? 0.5s : 1.5s // Slower for AWDL stability
```
### Deterministic Leader/Follower Protocol
To prevent connection races (both devices trying to invite each other), the app uses a **deterministic leader election**:
```swift
if myNodeID > theirNodeID {
// I am LEADER - I will send the invite
} else {
// I am FOLLOWER - I wait for their invite
}
```
This ensures exactly one device initiates each connection.
---
## Handshake Protocol
Once connected at the socket level, devices exchange **handshake messages** containing:
```swift
struct MeshMessage {
var senderNodeID: String // Stable identity
var senderInstance: Int // Session counter (for ghost detection)
var senderRole: UserRole // Deaf or Hearing
var senderColorHex: String // Aura color
var isHandshake: Bool // Identifies this as handshake
}
```
The handshake:
1. Registers the peer in `connectedPeerUsers` for UI display
2. Starts a **15-second stability timer** before clearing failure counters
3. Maps the `MCPeerID` to the stable `nodeID` for reliable identification
---
## User Leaving the Conversation
### Explicit Leave (`ChatView.swift`)
When a user taps **"Leave"**:
```swift
Button(action: {
speechRecognizer.stopRecording() // Stop audio transcription
multipeerSession.stop() // Disconnect from mesh
isOnboardingComplete = false // Return to onboarding
})
```
### Disconnect Cleanup (`MultipeerSession.disconnect()`)
The `disconnect()` function performs complete cleanup:
1. **Cancel pending work**: Recovery tasks, connection timers
2. **Stop services**: Advertising and browsing
3. **Clear delegates**: Prevent zombie callbacks
4. **Disconnect session**: `session?.disconnect()`
5. **Clear all state**:
- `connectedPeers` / `connectedPeerUsers`
- `pendingInvites` / `latestByNodeID`
- `cooldownUntil` / `consecutiveFailures`
6. **Stop keep-alive heartbeats**
### Partial Transcript Preservation
If a peer disconnects mid-speech, their **partial transcript is preserved** as a final message:
```swift
if let partialText = liveTranscripts[peerKey], !partialText.isEmpty {
let finalMessage = MeshMessage(content: partialText, ...)
receivedMessages.append(finalMessage)
}
```
---
## Rejoining the Conversation
### Identity Recovery
When a user returns to the conversation:
1. App resets `isOnboardingComplete = false` on every launch (intentional - forces Login screen)
2. User completes onboarding again (name/role/color preserved in `@AppStorage`)
3. `multipeerSession.start()` called again
### Instance Increment
The key to reliable rejoining is the **instance counter**:
```swift
myInstance = NodeIdentity.nextInstance() // Monotonically increasing
```
When other devices see the new instance:
1. **Ghost Detection**: Old connections with lower instances are rejected
2. **Cooldown Clear**: Any cooldowns from previous failures are removed
3. **Fresh Connect**: The leader initiates a new invitation
### Handling Stale Peers
The mesh uses multiple mechanisms to handle rejoins:
| Mechanism | Purpose |
|-----------|---------|
| **Ghost Filtering** | Reject messages/invites from older instances |
| **Cooldown Clear** | Give returning peers a fresh chance |
| **Half-Open Deadlock Fix** | If we think we're connected but they invite us, accept the new invite |
| **Stability Timer** | Only reset failure counts after 15s of stable connection |
---
## Keep-Alive & Mesh Health
### Heartbeat System
When connected, the mesh sends **heartbeats every 10 seconds**:
```swift
let message = MeshMessage(
content: "💓",
isKeepAlive: true,
connectedNodeIDs: connectedPeerUsers.map { $0.nodeID } // Gossip
)
```
### Gossip Protocol
Heartbeats include a list of connected peers, enabling **clique repair**:
1. Device A receives heartbeat from Device B
2. If B knows Device C but A doesn't, A can proactively invite C
3. This heals mesh partitions without requiring everyone to be discoverable
---
## Connection Recovery
### Exponential Backoff
Failed connections trigger increasing cooldown periods:
```swift
// 0.5s 1.0s 2.0s 4.0s ... max 30s
let delay = min(0.5 * pow(2, failures - 1), 30.0)
```
### Smart Retry
Instead of restarting everything, failed connections are retried individually:
1. Only the **leader** initiates retries (prevents race conditions)
2. Retries respect cooldown periods
3. After 5 consecutive failures → **"Poisoned State"** triggers full reset
### Poisoned State Recovery
If a peer has too many consecutive failures:
```swift
if failures >= 5 {
restartServices(forcePoisonedRecovery: true)
// Creates new MCPeerID, clears all cooldowns
}
```
---
## Summary
| Event | What Happens |
|-------|--------------|
| **User joins** | NodeID retrieved, instance incremented, advertise + browse started |
| **On Wi-Fi** | Fast handshake (0.5s), near-instant connections |
| **On 5G/Cellular** | AWDL used, slower handshake (1.5s), up to 60s to connect |
| **User leaves** | Full cleanup, partial transcripts preserved |
| **User rejoins** | New instance number, ghosts filtered, cooldowns cleared |
| **Connection fails** | Exponential backoff, smart retry by leader only |
The architecture prioritizes **reliability over speed**, using defensive mechanisms like ghost filtering, stability timers, and gossip-based clique repair to maintain mesh health despite the inherent unreliability of peer-to-peer wireless connections.

View File

@@ -9,7 +9,7 @@ The app prioritizes a stress-free environment for deaf users, utilizing calming
## 🌟 Key Features
### Core Communication
* **Full Mesh Networking:** Devices enter a peer-to-peer full mesh network using Apple's Multipeer Connectivity. No manual pairing or internet connection is required to chat.
* **Full Mesh Networking:** Devices enter a peer-to-peer full mesh network using Apple's Network.framework (NWConnection/NWBrowser) with AWDL support. No manual pairing or internet connection is required to chat.
* **Real-Time Transcription (Hearing Role):** Hearing users speak naturally. The app transcribes speech in real-time and automatically posts the text as a message to the group once the speaker pauses.
* **Text & Quick Replies (Deaf Role):** Deaf users can type messages or use one-tap "Quick Reply" chips (Yes, No, Hold on, Thanks) for rapid interaction.
* **Role-Based UI:** Distinct interfaces tailored for "Deaf/HoH" and "Hearing" users, optimized for their specific communication needs.

1257
transition.md Normal file

File diff suppressed because it is too large Load Diff