Compare commits
3 Commits
backup_bef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 91350d833e | |||
| 91e0594d71 | |||
| 15b7cc3e1b |
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
|
||||
567
AtTable/MeshNetworkManager.swift
Normal file
567
AtTable/MeshNetworkManager.swift
Normal 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
88
AtTable/NetworkFraming.swift
Normal file
88
AtTable/NetworkFraming.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
1257
transition.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user