switch to network.framework
This commit is contained in:
@@ -272,7 +272,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.AtTable;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.AtTable;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.AtTable;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.AtTable;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct AtTableApp: App {
|
|||||||
@AppStorage("userColorHex") var userColorHex: String = "#00008B"
|
@AppStorage("userColorHex") var userColorHex: String = "#00008B"
|
||||||
|
|
||||||
// APP-LEVEL SESSION: Ensures stable identity (Instance ID) across view reloads
|
// APP-LEVEL SESSION: Ensures stable identity (Instance ID) across view reloads
|
||||||
@StateObject var multipeerSession = MultipeerSession()
|
@StateObject var meshManager = MeshNetworkManager()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Reset navigation state on launch so users always see the "Login" screen first.
|
// 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 SwiftUI
|
||||||
import MultipeerConnectivity
|
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
struct ChatView: View {
|
struct ChatView: View {
|
||||||
@EnvironmentObject var multipeerSession: MultipeerSession
|
@EnvironmentObject var meshManager: MeshNetworkManager
|
||||||
@StateObject var speechRecognizer = SpeechRecognizer()
|
@StateObject var speechRecognizer = SpeechRecognizer()
|
||||||
@ObservedObject var networkMonitor = NetworkMonitor.shared
|
@ObservedObject var networkMonitor = NetworkMonitor.shared
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ struct ChatView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
speechRecognizer.stopRecording()
|
speechRecognizer.stopRecording()
|
||||||
multipeerSession.stop()
|
meshManager.stop()
|
||||||
isOnboardingComplete = false
|
isOnboardingComplete = false
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
@@ -53,7 +52,7 @@ struct ChatView: View {
|
|||||||
.tracking(2)
|
.tracking(2)
|
||||||
.foregroundColor(.white.opacity(0.6))
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
|
||||||
Text("\(multipeerSession.connectedPeers.count) Active")
|
Text("\(meshManager.connectedPeerUsers.count) Active")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
@@ -68,7 +67,7 @@ struct ChatView: View {
|
|||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
// 3. Peer Status / Live Transcriptions
|
// 3. Peer Status / Live Transcriptions
|
||||||
if multipeerSession.connectedPeers.isEmpty {
|
if meshManager.connectedPeerUsers.isEmpty {
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -100,11 +99,11 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Connected Peers Row (Small Pills)
|
// Connected Peers Row (Small Pills)
|
||||||
if !multipeerSession.connectedPeerUsers.isEmpty {
|
if !meshManager.connectedPeerUsers.isEmpty {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ForEach(multipeerSession.connectedPeerUsers, id: \.self) { peer in
|
ForEach(meshManager.connectedPeerUsers, id: \.self) { peer in
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color(hex: peer.colorHex))
|
.fill(Color(hex: peer.colorHex))
|
||||||
@@ -124,7 +123,7 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Full Mesh Indicator
|
// Full Mesh Indicator
|
||||||
if multipeerSession.isAtCapacity {
|
if meshManager.isAtCapacity {
|
||||||
Text("FULL")
|
Text("FULL")
|
||||||
.font(.system(size: 10, weight: .black, design: .monospaced))
|
.font(.system(size: 10, weight: .black, design: .monospaced))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
@@ -142,10 +141,10 @@ struct ChatView: View {
|
|||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 12) {
|
LazyVStack(spacing: 12) {
|
||||||
ForEach(multipeerSession.receivedMessages) { message in
|
ForEach(meshManager.receivedMessages) { message in
|
||||||
MessageBubble(
|
MessageBubble(
|
||||||
message: message,
|
message: message,
|
||||||
isMyMessage: message.senderNodeID == multipeerSession.myNodeIDPublic
|
isMyMessage: message.senderNodeID == meshManager.myNodeIDPublic
|
||||||
)
|
)
|
||||||
.id(message.id)
|
.id(message.id)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||||
@@ -154,25 +153,25 @@ struct ChatView: View {
|
|||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.hidden)
|
.scrollIndicators(.hidden)
|
||||||
.onChange(of: multipeerSession.receivedMessages.count) {
|
.onChange(of: meshManager.receivedMessages.count) {
|
||||||
if let lastId = multipeerSession.receivedMessages.last?.id {
|
if let lastId = meshManager.receivedMessages.last?.id {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
proxy.scrollTo(lastId, anchor: .bottom)
|
proxy.scrollTo(lastId, anchor: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if userRole == .deaf {
|
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()
|
let generator = UINotificationFeedbackGenerator()
|
||||||
generator.notificationOccurred(.success)
|
generator.notificationOccurred(.success)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Auto-scroll when transcription card appears to prevent blocking
|
// Auto-scroll when transcription card appears to prevent blocking
|
||||||
.onChange(of: multipeerSession.liveTranscripts.isEmpty) {
|
.onChange(of: meshManager.liveTranscripts.isEmpty) {
|
||||||
if !multipeerSession.liveTranscripts.isEmpty {
|
if !meshManager.liveTranscripts.isEmpty {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
if let lastId = multipeerSession.receivedMessages.last?.id {
|
if let lastId = meshManager.receivedMessages.last?.id {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
proxy.scrollTo(lastId, anchor: .bottom)
|
proxy.scrollTo(lastId, anchor: .bottom)
|
||||||
}
|
}
|
||||||
@@ -185,11 +184,11 @@ struct ChatView: View {
|
|||||||
|
|
||||||
// Live Transcription Cards (Moved to Bottom)
|
// Live Transcription Cards (Moved to Bottom)
|
||||||
// Live Transcription Cards (Moved to Bottom, Stacked Vertically)
|
// Live Transcription Cards (Moved to Bottom, Stacked Vertically)
|
||||||
if userRole == .deaf && !multipeerSession.liveTranscripts.isEmpty {
|
if userRole == .deaf && !meshManager.liveTranscripts.isEmpty {
|
||||||
VStack(spacing: 8) {
|
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
|
// 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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(displayName)
|
Text(displayName)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
@@ -242,11 +241,11 @@ struct ChatView: View {
|
|||||||
.ignoresSafeArea(.container, edges: .all)
|
.ignoresSafeArea(.container, edges: .all)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
UIApplication.shared.isIdleTimerDisabled = true
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
multipeerSession.start()
|
meshManager.start()
|
||||||
|
|
||||||
// Re-apply identity if needed (though App state should handle it)
|
// Re-apply identity if needed (though App state should handle it)
|
||||||
if multipeerSession.userName != userName {
|
if meshManager.userName != userName {
|
||||||
multipeerSession.setIdentity(name: userName, colorHex: userColorHex, role: userRole)
|
meshManager.setIdentity(name: userName, colorHex: userColorHex, role: userRole)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure Speech Recognizer callback
|
// Configure Speech Recognizer callback
|
||||||
@@ -254,8 +253,8 @@ struct ChatView: View {
|
|||||||
let message = MeshMessage(
|
let message = MeshMessage(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
senderID: userName,
|
senderID: userName,
|
||||||
senderNodeID: multipeerSession.myNodeIDPublic,
|
senderNodeID: meshManager.myNodeIDPublic,
|
||||||
senderInstance: multipeerSession.myInstancePublic,
|
senderInstance: meshManager.myInstancePublic,
|
||||||
senderRole: userRole,
|
senderRole: userRole,
|
||||||
senderColorHex: userColorHex,
|
senderColorHex: userColorHex,
|
||||||
content: resultText,
|
content: resultText,
|
||||||
@@ -263,7 +262,7 @@ struct ChatView: View {
|
|||||||
isPartial: false,
|
isPartial: false,
|
||||||
timestamp: Date()
|
timestamp: Date()
|
||||||
)
|
)
|
||||||
multipeerSession.send(message: message)
|
meshManager.send(message: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure Partial Result callback (No Throttling)
|
// Configure Partial Result callback (No Throttling)
|
||||||
@@ -271,8 +270,8 @@ struct ChatView: View {
|
|||||||
let message = MeshMessage(
|
let message = MeshMessage(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
senderID: userName,
|
senderID: userName,
|
||||||
senderNodeID: multipeerSession.myNodeIDPublic, // Added NodeID for consistent transcript keying
|
senderNodeID: meshManager.myNodeIDPublic, // Added NodeID for consistent transcript keying
|
||||||
senderInstance: multipeerSession.myInstancePublic,
|
senderInstance: meshManager.myInstancePublic,
|
||||||
senderRole: userRole,
|
senderRole: userRole,
|
||||||
senderColorHex: userColorHex,
|
senderColorHex: userColorHex,
|
||||||
content: partialText,
|
content: partialText,
|
||||||
@@ -280,7 +279,7 @@ struct ChatView: View {
|
|||||||
isPartial: true,
|
isPartial: true,
|
||||||
timestamp: Date()
|
timestamp: Date()
|
||||||
)
|
)
|
||||||
multipeerSession.send(message: message)
|
meshManager.send(message: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-start recording for Hearing users
|
// Auto-start recording for Hearing users
|
||||||
@@ -299,8 +298,8 @@ struct ChatView: View {
|
|||||||
let message = MeshMessage(
|
let message = MeshMessage(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
senderID: userName,
|
senderID: userName,
|
||||||
senderNodeID: multipeerSession.myNodeIDPublic, // Added NodeID
|
senderNodeID: meshManager.myNodeIDPublic, // Added NodeID
|
||||||
senderInstance: multipeerSession.myInstancePublic,
|
senderInstance: meshManager.myInstancePublic,
|
||||||
senderRole: userRole,
|
senderRole: userRole,
|
||||||
senderColorHex: userColorHex,
|
senderColorHex: userColorHex,
|
||||||
content: messageText,
|
content: messageText,
|
||||||
@@ -309,7 +308,7 @@ struct ChatView: View {
|
|||||||
timestamp: Date()
|
timestamp: Date()
|
||||||
)
|
)
|
||||||
|
|
||||||
multipeerSession.send(message: message)
|
meshManager.send(message: message)
|
||||||
messageText = ""
|
messageText = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,12 +56,17 @@ class MeshNetworkManager: NSObject, ObservableObject {
|
|||||||
// Pending Connections (to avoid duplicates)
|
// Pending Connections (to avoid duplicates)
|
||||||
private var pendingConnections: Set<String> = []
|
private var pendingConnections: Set<String> = []
|
||||||
|
|
||||||
|
// Store endpoints for retry
|
||||||
|
private var knownEndpoints: [String: NWEndpoint] = [:]
|
||||||
|
|
||||||
// Internal Peer Map (before they become "connectedPeerUsers" or while unstable)
|
// Internal Peer Map (before they become "connectedPeerUsers" or while unstable)
|
||||||
// nodeID -> PeerUser
|
// nodeID -> PeerUser
|
||||||
private var internalPeerMap: [String: PeerUser] = [:]
|
private var internalPeerMap: [String: PeerUser] = [:]
|
||||||
|
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
private let log = Logger()
|
private lazy var log = Logger(nameProvider: { [weak self] in
|
||||||
|
self?.myUserName ?? UIDevice.current.name
|
||||||
|
})
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
@@ -200,6 +205,23 @@ class MeshNetworkManager: NSObject, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleIncomingConnection(_ connection: NWConnection) {
|
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)
|
connection.start(queue: .main)
|
||||||
|
|
||||||
// Start receiving immediately to get the Identification message
|
// Start receiving immediately to get the Identification message
|
||||||
@@ -207,16 +229,38 @@ class MeshNetworkManager: NSObject, ObservableObject {
|
|||||||
self?.handleData(data, from: connection)
|
self?.handleData(data, from: connection)
|
||||||
}, onError: { [weak self] error in
|
}, onError: { [weak self] error in
|
||||||
self?.log.error("Receive error on incoming connection: \(error)")
|
self?.log.error("Receive error on incoming connection: \(error)")
|
||||||
connection.cancel()
|
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)
|
// MARK: - Browser (Client)
|
||||||
|
|
||||||
private func startBrowser() {
|
private func startBrowser() {
|
||||||
let parameters = createParameters()
|
let parameters = createParameters()
|
||||||
browser = NWBrowser(for: .bonjour(type: serviceType, domain: nil), using: parameters)
|
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
|
browser?.browseResultsChangedHandler = { [weak self] results, changes in
|
||||||
self?.handleBrowseResults(results, changes: changes)
|
self?.handleBrowseResults(results, changes: changes)
|
||||||
}
|
}
|
||||||
@@ -231,7 +275,13 @@ class MeshNetworkManager: NSObject, ObservableObject {
|
|||||||
handleDiscovered(result)
|
handleDiscovered(result)
|
||||||
case .removed(let result):
|
case .removed(let result):
|
||||||
handleLost(result)
|
handleLost(result)
|
||||||
default: break
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,37 +299,61 @@ class MeshNetworkManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
guard peerNodeID != myNodeID else { return }
|
guard peerNodeID != myNodeID else { return }
|
||||||
|
|
||||||
// GHOST FILTER: Check if we know a newer instance
|
// 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 let existingInst = peerInstances[peerNodeID] {
|
||||||
if peerInstance < existingInst {
|
// If discovered instance is 0, TXT record wasn't available
|
||||||
log.info("Ignoring older instance for \(peerNodeID): \(peerInstance) < \(existingInst)")
|
// 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
|
return
|
||||||
} else if peerInstance > existingInst {
|
}
|
||||||
log.info("Discovered NEWER instance \(peerInstance) > \(existingInst) for \(peerNodeID). Disconnecting stale connection.")
|
|
||||||
// Force disconnect old connection to allow re-initiation
|
|
||||||
if let conn = connections[peerNodeID] {
|
|
||||||
conn.cancel()
|
|
||||||
connections.removeValue(forKey: peerNodeID)
|
|
||||||
pendingConnections.remove(peerNodeID)
|
|
||||||
stableNodeIDs.remove(peerNodeID)
|
|
||||||
internalPeerMap.removeValue(forKey: peerNodeID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Discovered \(peerNodeID) (inst: \(peerInstance))")
|
// Store endpoint for potential retry
|
||||||
|
knownEndpoints[peerNodeID] = result.endpoint
|
||||||
|
|
||||||
// Tie-Breaker: Lower ID initiates connection
|
// Check if we already have a connection or one is pending
|
||||||
if myNodeID < peerNodeID {
|
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)
|
initiateConnection(to: result.endpoint, peerNodeID: peerNodeID)
|
||||||
} else {
|
|
||||||
log.info("Waiting for \(peerNodeID) to connect (I have higher ID)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleLost(_ result: NWBrowser.Result) {
|
private func handleLost(_ result: NWBrowser.Result) {
|
||||||
if case .service(let name, _, _, _) = result.endpoint {
|
guard case .service(let name, _, _, _) = result.endpoint else { return }
|
||||||
log.info("Browser lost service: \(name)")
|
|
||||||
|
// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +364,6 @@ class MeshNetworkManager: NSObject, ObservableObject {
|
|||||||
guard !pendingConnections.contains(peerNodeID) else { return }
|
guard !pendingConnections.contains(peerNodeID) else { return }
|
||||||
|
|
||||||
pendingConnections.insert(peerNodeID)
|
pendingConnections.insert(peerNodeID)
|
||||||
log.info("Initiating connection to \(peerNodeID)")
|
|
||||||
|
|
||||||
let parameters = createParameters()
|
let parameters = createParameters()
|
||||||
let connection = NWConnection(to: endpoint, using: parameters)
|
let connection = NWConnection(to: endpoint, using: parameters)
|
||||||
@@ -302,6 +375,36 @@ class MeshNetworkManager: NSObject, ObservableObject {
|
|||||||
connection.start(queue: .main)
|
connection.start(queue: .main)
|
||||||
connections[peerNodeID] = connection
|
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
|
NetworkFraming.startReceiving(from: connection, onData: { [weak self] data in
|
||||||
self?.handleData(data, from: connection)
|
self?.handleData(data, from: connection)
|
||||||
}, onError: { [weak self] error in
|
}, onError: { [weak self] error in
|
||||||
@@ -452,7 +555,13 @@ class MeshNetworkManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Logger Helper
|
// MARK: - Logger Helper
|
||||||
struct Logger {
|
struct Logger {
|
||||||
func info(_ msg: String) { print("🕸️ [Mesh] \(msg)") }
|
let nameProvider: () -> String
|
||||||
func error(_ msg: String) { print("🕸️ ❌ [Mesh] \(msg)") }
|
|
||||||
|
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 Foundation
|
||||||
import MultipeerConnectivity
|
|
||||||
|
|
||||||
struct PeerUser: Identifiable, Hashable {
|
struct PeerUser: Identifiable, Hashable {
|
||||||
let id: MCPeerID
|
let id: String // Use nodeID as the primary stable ID
|
||||||
let nodeID: String // Stable identifier from handshake (for reliable identity)
|
let nodeID: String
|
||||||
let name: String
|
let name: String
|
||||||
let colorHex: 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) {
|
init(nodeID: String, name: String, colorHex: String = "#808080", role: UserRole = .hearing, isStable: Bool = false) {
|
||||||
self.id = peerID
|
self.id = nodeID
|
||||||
self.nodeID = nodeID
|
self.nodeID = nodeID
|
||||||
self.name = name ?? peerID.displayName
|
self.name = name
|
||||||
self.colorHex = colorHex
|
self.colorHex = colorHex
|
||||||
self.role = role
|
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.
|
|
||||||
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