mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 04:45:20 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6550a64676 | ||
|
|
7e9d573f6d | ||
|
|
ed3583f2ff | ||
|
|
2251afa360 | ||
|
|
c39df29b25 | ||
|
|
43e159df0e | ||
|
|
adab49154b | ||
|
|
62d259cd9c | ||
|
|
131db9c569 | ||
|
|
f5a2a1e29a | ||
|
|
3a8cf4d37f | ||
|
|
b243b930c4 | ||
|
|
c803f862d5 | ||
|
|
58c516075c |
@@ -328,6 +328,7 @@
|
|||||||
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
||||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
||||||
9F37F9F2C353B58AC809E93B /* Products */,
|
9F37F9F2C353B58AC809E93B /* Products */,
|
||||||
|
048A4BE52E5CCC5C00162C4A /* Recovered References */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -742,13 +743,14 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||||
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
|
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
|
||||||
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
||||||
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
||||||
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
|
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||||
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
|
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
|
||||||
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
|
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
|
||||||
|
|
||||||
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
|
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
|
||||||
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */,
|
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */,
|
||||||
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
|
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
|
||||||
@@ -801,13 +803,14 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||||
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
|
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
|
||||||
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
||||||
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
||||||
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
|
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||||
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
|
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
|
||||||
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
|
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
|
||||||
|
|
||||||
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
|
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
|
||||||
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */,
|
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */,
|
||||||
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
|
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
|
||||||
@@ -1004,7 +1007,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -1035,7 +1038,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1090,7 +1093,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -1122,7 +1125,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1211,7 +1214,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -1304,7 +1307,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.3;
|
MARKETING_VERSION = 1.3.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@@ -106,8 +106,6 @@ enum HandshakeState {
|
|||||||
struct CryptographicIdentity: Codable {
|
struct CryptographicIdentity: Codable {
|
||||||
let fingerprint: String // SHA256 of public key
|
let fingerprint: String // SHA256 of public key
|
||||||
let publicKey: Data // Noise static public key
|
let publicKey: Data // Noise static public key
|
||||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
|
||||||
var signingPublicKey: Data? = nil
|
|
||||||
let firstSeen: Date
|
let firstSeen: Date
|
||||||
let lastHandshake: Date?
|
let lastHandshake: Date?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,91 +229,6 @@ class SecureIdentityStateManager {
|
|||||||
return cache.socialIdentities[fingerprint]
|
return cache.socialIdentities[fingerprint]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cryptographic Identities
|
|
||||||
|
|
||||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
|
||||||
/// - Parameters:
|
|
||||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
|
||||||
/// - noisePublicKey: Noise static public key data
|
|
||||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
|
||||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
|
||||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
let now = Date()
|
|
||||||
if var existing = self.cryptographicIdentities[fingerprint] {
|
|
||||||
// Update keys if changed
|
|
||||||
if existing.publicKey != noisePublicKey {
|
|
||||||
existing = CryptographicIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
publicKey: noisePublicKey,
|
|
||||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
|
||||||
firstSeen: existing.firstSeen,
|
|
||||||
lastHandshake: now
|
|
||||||
)
|
|
||||||
self.cryptographicIdentities[fingerprint] = existing
|
|
||||||
} else {
|
|
||||||
// Update signing key and lastHandshake
|
|
||||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
|
||||||
let updated = CryptographicIdentity(
|
|
||||||
fingerprint: existing.fingerprint,
|
|
||||||
publicKey: existing.publicKey,
|
|
||||||
signingPublicKey: existing.signingPublicKey,
|
|
||||||
firstSeen: existing.firstSeen,
|
|
||||||
lastHandshake: now
|
|
||||||
)
|
|
||||||
self.cryptographicIdentities[fingerprint] = updated
|
|
||||||
}
|
|
||||||
// Persist updated state (already assigned in branches above)
|
|
||||||
} else {
|
|
||||||
// New entry
|
|
||||||
let entry = CryptographicIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
publicKey: noisePublicKey,
|
|
||||||
signingPublicKey: signingPublicKey,
|
|
||||||
firstSeen: now,
|
|
||||||
lastHandshake: now
|
|
||||||
)
|
|
||||||
self.cryptographicIdentities[fingerprint] = entry
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionally persist claimed nickname into social identity
|
|
||||||
if let claimed = claimedNickname {
|
|
||||||
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
localPetname: nil,
|
|
||||||
claimedNickname: claimed,
|
|
||||||
trustLevel: .unknown,
|
|
||||||
isFavorite: false,
|
|
||||||
isBlocked: false,
|
|
||||||
notes: nil
|
|
||||||
)
|
|
||||||
// Update claimed nickname if changed
|
|
||||||
if identity.claimedNickname != claimed {
|
|
||||||
identity.claimedNickname = claimed
|
|
||||||
self.cache.socialIdentities[fingerprint] = identity
|
|
||||||
} else if self.cache.socialIdentities[fingerprint] == nil {
|
|
||||||
self.cache.socialIdentities[fingerprint] = identity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.saveIdentityCache()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve cryptographic identity by fingerprint
|
|
||||||
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
|
|
||||||
queue.sync { cryptographicIdentities[fingerprint] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
|
||||||
queue.sync {
|
|
||||||
// Defensive: ensure hex and correct length
|
|
||||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
|
||||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAllSocialIdentities() -> [SocialIdentity] {
|
func getAllSocialIdentities() -> [SocialIdentity] {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
let nickname: String
|
let nickname: String
|
||||||
let lastSeen: Date
|
let lastSeen: Date
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let isReachable: Bool
|
|
||||||
|
|
||||||
// Favorite-related properties
|
// Favorite-related properties
|
||||||
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
||||||
@@ -19,7 +18,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
// Connection state
|
// Connection state
|
||||||
enum ConnectionState {
|
enum ConnectionState {
|
||||||
case bluetoothConnected
|
case bluetoothConnected
|
||||||
case meshReachable // Seen via mesh recently, not directly connected
|
|
||||||
case nostrAvailable // Mutual favorite, reachable via Nostr
|
case nostrAvailable // Mutual favorite, reachable via Nostr
|
||||||
case offline // Not connected via any transport
|
case offline // Not connected via any transport
|
||||||
}
|
}
|
||||||
@@ -27,8 +25,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
var connectionState: ConnectionState {
|
var connectionState: ConnectionState {
|
||||||
if isConnected {
|
if isConnected {
|
||||||
return .bluetoothConnected
|
return .bluetoothConnected
|
||||||
} else if isReachable {
|
|
||||||
return .meshReachable
|
|
||||||
} else if favoriteStatus?.isMutual == true {
|
} else if favoriteStatus?.isMutual == true {
|
||||||
// Mutual favorites can communicate via Nostr when offline
|
// Mutual favorites can communicate via Nostr when offline
|
||||||
return .nostrAvailable
|
return .nostrAvailable
|
||||||
@@ -58,8 +54,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
switch connectionState {
|
switch connectionState {
|
||||||
case .bluetoothConnected:
|
case .bluetoothConnected:
|
||||||
return "📻" // Radio icon for mesh connection
|
return "📻" // Radio icon for mesh connection
|
||||||
case .meshReachable:
|
|
||||||
return "📡" // Antenna for mesh reachable
|
|
||||||
case .nostrAvailable:
|
case .nostrAvailable:
|
||||||
return "🌐" // Purple globe for Nostr
|
return "🌐" // Purple globe for Nostr
|
||||||
case .offline:
|
case .offline:
|
||||||
@@ -77,15 +71,13 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen: Date = Date(),
|
lastSeen: Date = Date(),
|
||||||
isConnected: Bool = false,
|
isConnected: Bool = false
|
||||||
isReachable: Bool = false
|
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
self.lastSeen = lastSeen
|
||||||
self.isConnected = isConnected
|
self.isConnected = isConnected
|
||||||
self.isReachable = isReachable
|
|
||||||
|
|
||||||
// Load favorite status - will be set later by the manager
|
// Load favorite status - will be set later by the manager
|
||||||
self.favoriteStatus = nil
|
self.favoriteStatus = nil
|
||||||
|
|||||||
+121
-428
@@ -102,12 +102,6 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Queue for messages pending handshake completion
|
// Queue for messages pending handshake completion
|
||||||
private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:]
|
private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:]
|
||||||
// Noise typed payloads (ACKs, read receipts, etc.) pending handshake
|
|
||||||
private var pendingNoisePayloadsAfterHandshake: [String: [Data]] = [:]
|
|
||||||
// Keep a tiny buffer of the last few unique announces we've seen (by sender)
|
|
||||||
private var recentAnnounceBySender: [String: BitchatPacket] = [:]
|
|
||||||
private var recentAnnounceOrder: [String] = []
|
|
||||||
private let recentAnnounceBufferCap = 3
|
|
||||||
|
|
||||||
// Queue for notifications that failed due to full queue
|
// Queue for notifications that failed due to full queue
|
||||||
private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = []
|
private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = []
|
||||||
@@ -128,13 +122,6 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Backpressure-aware write queue per peripheral
|
// Backpressure-aware write queue per peripheral
|
||||||
private var pendingPeripheralWrites: [String: [Data]] = [:]
|
private var pendingPeripheralWrites: [String: [Data]] = [:]
|
||||||
// Debounce duplicate disconnect notifies
|
|
||||||
private var recentDisconnectNotifies: [String: Date] = [:]
|
|
||||||
// Store-and-forward for directed messages when we have no links
|
|
||||||
// Keyed by recipient short peerID -> messageID -> (packet, enqueuedAt)
|
|
||||||
private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
|
|
||||||
// Debounce for 'reconnected' logs
|
|
||||||
private var lastReconnectLogAt: [String: Date] = [:]
|
|
||||||
|
|
||||||
// MARK: - Maintenance Timer
|
// MARK: - Maintenance Timer
|
||||||
|
|
||||||
@@ -320,11 +307,6 @@ final class BLEService: NSObject {
|
|||||||
// Send any messages that were queued during handshake
|
// Send any messages that were queued during handshake
|
||||||
self?.messageQueue.async { [weak self] in
|
self?.messageQueue.async { [weak self] in
|
||||||
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
||||||
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
|
||||||
}
|
|
||||||
// Proactive presence nudge: announce immediately after handshake
|
|
||||||
self?.messageQueue.async { [weak self] in
|
|
||||||
self?.sendAnnounce(forceSend: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,27 +502,6 @@ final class BLEService: NSObject {
|
|||||||
return collectionsQueue.sync { peers[shortID]?.isConnected ?? false }
|
return collectionsQueue.sync { peers[shortID]?.isConnected ?? false }
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPeerReachable(_ peerID: String) -> Bool {
|
|
||||||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
|
||||||
let shortID: String = {
|
|
||||||
if peerID.count == 64, let key = Data(hexString: peerID) {
|
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
|
||||||
}
|
|
||||||
return peerID
|
|
||||||
}()
|
|
||||||
return collectionsQueue.sync {
|
|
||||||
// Must be mesh-attached: at least one live direct link to the mesh
|
|
||||||
let meshAttached = peers.values.contains { $0.isConnected }
|
|
||||||
guard let info = peers[shortID] else { return false }
|
|
||||||
if info.isConnected { return true }
|
|
||||||
guard meshAttached else { return false }
|
|
||||||
// Apply reachability retention window
|
|
||||||
let isVerified = info.isVerifiedNickname
|
|
||||||
let retention: TimeInterval = isVerified ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
|
||||||
return Date().timeIntervalSince(info.lastSeen) <= retention
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func peerNickname(peerID: String) -> String? {
|
func peerNickname(peerID: String) -> String? {
|
||||||
collectionsQueue.sync {
|
collectionsQueue.sync {
|
||||||
guard let peer = peers[peerID], peer.isConnected else { return nil }
|
guard let peer = peers[peerID], peer.isConnected else { return nil }
|
||||||
@@ -580,41 +541,43 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||||
// Create typed payload: [type byte] + [message ID]
|
// Send encrypted read receipt
|
||||||
var payload = Data([NoisePayloadType.readReceipt.rawValue])
|
guard noiseService.hasSession(with: peerID) else {
|
||||||
payload.append(contentsOf: receipt.originalMessageID.utf8)
|
SecureLogger.log("Cannot send read receipt - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning)
|
||||||
|
return
|
||||||
if noiseService.hasEstablishedSession(with: peerID) {
|
}
|
||||||
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)",
|
||||||
do {
|
category: SecureLogger.session, level: .debug)
|
||||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
|
||||||
let packet = BitchatPacket(
|
// Create read receipt payload: [type byte] + [message ID]
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue])
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
receiptPayload.append(contentsOf: receipt.originalMessageID.utf8)
|
||||||
recipientID: Data(hexString: peerID),
|
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
do {
|
||||||
payload: encrypted,
|
let encrypted = try noiseService.encrypt(receiptPayload, for: peerID)
|
||||||
signature: nil,
|
let packet = BitchatPacket(
|
||||||
ttl: messageTTL
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
)
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
recipientID: Data(hexString: peerID),
|
||||||
broadcastPacket(packet)
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
} else {
|
payload: encrypted,
|
||||||
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
signature: nil,
|
||||||
|
ttl: messageTTL
|
||||||
|
)
|
||||||
|
|
||||||
|
// If already on messageQueue, call directly
|
||||||
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
|
broadcastPacket(packet)
|
||||||
|
} else {
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
self?.broadcastPacket(packet)
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Queue for after handshake and initiate if needed
|
// Read receipt sent
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
} catch {
|
||||||
guard let self = self else { return }
|
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error)
|
||||||
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
|
|
||||||
}
|
|
||||||
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
|
|
||||||
SecureLogger.log("🕒 Queued READ receipt for \(peerID) until handshake completes",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,26 +697,19 @@ final class BLEService: NSObject {
|
|||||||
self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID)
|
self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID)
|
||||||
} else {
|
} else {
|
||||||
// Public broadcast
|
// Public broadcast
|
||||||
// Create packet with explicit fields so we can sign it
|
// Public message - logged at relay point for mesh debugging
|
||||||
let basePacket = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.message.rawValue,
|
type: MessageType.message.rawValue,
|
||||||
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
ttl: self.messageTTL,
|
||||||
recipientID: nil,
|
senderID: self.myPeerID,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
payload: Data(content.utf8)
|
||||||
payload: Data(content.utf8),
|
|
||||||
signature: nil,
|
|
||||||
ttl: self.messageTTL
|
|
||||||
)
|
)
|
||||||
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
|
|
||||||
SecureLogger.log("❌ Failed to sign public message", category: SecureLogger.security, level: .error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
|
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
|
||||||
let senderHex = signedPacket.senderID.hexEncodedString()
|
let senderHex = packet.senderID.hexEncodedString()
|
||||||
let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)"
|
let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)"
|
||||||
self.messageDeduplicator.markProcessed(dedupID)
|
self.messageDeduplicator.markProcessed(dedupID)
|
||||||
// Call synchronously since we're already on background queue
|
// Call synchronously since we're already on background queue
|
||||||
self.broadcastPacket(signedPacket)
|
self.broadcastPacket(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1081,23 +1037,15 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
|
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
|
||||||
// Special-case announces: do NOT subset to maximize reach for presence
|
|
||||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
||||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
var selectedCentralIDs = Set(allowedCentralIDs)
|
||||||
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue {
|
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue {
|
||||||
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
|
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
|
||||||
let kc = subsetSizeForFanout(allowedCentralIDs.count)
|
let kc = subsetSizeForFanout(allowedCentralIDs.count)
|
||||||
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
|
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
|
||||||
selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)
|
selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If directed and we currently have no links to forward on, spool for a short window
|
|
||||||
if let only = directedOnlyPeer,
|
|
||||||
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
|
|
||||||
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
|
|
||||||
spoolDirectedPacket(packet, recipientPeerID: only)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Writes to selected connected peripherals
|
// Writes to selected connected peripherals
|
||||||
for s in states where s.isConnected {
|
for s in states where s.isConnected {
|
||||||
let pid = s.peripheral.identifier.uuidString
|
let pid = s.peripheral.identifier.uuidString
|
||||||
@@ -1114,57 +1062,6 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Directed store-and-forward
|
|
||||||
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
|
|
||||||
let msgID = makeMessageID(for: packet)
|
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:]
|
|
||||||
if byMsg[msgID] == nil {
|
|
||||||
byMsg[msgID] = (packet: packet, enqueuedAt: Date())
|
|
||||||
self.pendingDirectedRelays[recipientPeerID] = byMsg
|
|
||||||
SecureLogger.log("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: SecureLogger.session, level: .debug)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func flushDirectedSpool() {
|
|
||||||
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
|
||||||
let toSend: [(String, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) {
|
|
||||||
var out: [(String, BitchatPacket)] = []
|
|
||||||
let now = Date()
|
|
||||||
for (recipient, dict) in pendingDirectedRelays {
|
|
||||||
for (_, entry) in dict {
|
|
||||||
if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds {
|
|
||||||
out.append((recipient, entry.packet))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Clear recipient bucket; items will be re-spooled if still no links
|
|
||||||
pendingDirectedRelays.removeValue(forKey: recipient)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
guard !toSend.isEmpty else { return }
|
|
||||||
for (_, packet) in toSend {
|
|
||||||
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rebroadcastRecentAnnounces() {
|
|
||||||
// Snapshot sender order to preserve ordering and avoid holding locks while sending
|
|
||||||
let packets: [BitchatPacket] = collectionsQueue.sync {
|
|
||||||
recentAnnounceOrder.compactMap { recentAnnounceBySender[$0] }
|
|
||||||
}
|
|
||||||
guard !packets.isEmpty else { return }
|
|
||||||
for (idx, pkt) in packets.enumerated() {
|
|
||||||
// Stagger slightly to avoid bursts
|
|
||||||
let delayMs = idx * 20
|
|
||||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
|
||||||
self?.broadcastPacket(pkt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendData(_ data: Data, to peripheral: CBPeripheral) {
|
private func sendData(_ data: Data, to peripheral: CBPeripheral) {
|
||||||
// Fire-and-forget: Simple send without complex fallback logic
|
// Fire-and-forget: Simple send without complex fallback logic
|
||||||
@@ -1230,8 +1127,7 @@ final class BLEService: NSObject {
|
|||||||
ttl: packet.ttl
|
ttl: packet.ttl
|
||||||
)
|
)
|
||||||
// Pace fragments with small jitter to avoid bursts
|
// Pace fragments with small jitter to avoid bursts
|
||||||
let perFragMs = (directedOnlyPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs
|
let delayMs = index * TransportConfig.bleFragmentSpacingMs // ~6ms spacing per fragment
|
||||||
let delayMs = index * perFragMs
|
|
||||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
||||||
self?.broadcastPacket(fragmentPacket)
|
self?.broadcastPacket(fragmentPacket)
|
||||||
}
|
}
|
||||||
@@ -1326,14 +1222,10 @@ final class BLEService: NSObject {
|
|||||||
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)",
|
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
// In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging.
|
// Cancel any pending relay for this message (arrived via another neighbor)
|
||||||
// In denser graphs, cancel the pending relay to reduce redundant floods.
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
|
if let task = self?.scheduledRelays.removeValue(forKey: messageID) {
|
||||||
if connectedCount > 2 {
|
task.cancel()
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
||||||
if let task = self?.scheduledRelays.removeValue(forKey: messageID) {
|
|
||||||
task.cancel()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return // Duplicate ignored
|
return // Duplicate ignored
|
||||||
@@ -1392,7 +1284,6 @@ final class BLEService: NSObject {
|
|||||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
|
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
|
||||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
|
||||||
degree: degree,
|
degree: degree,
|
||||||
highDegreeThreshold: highDegreeThreshold
|
highDegreeThreshold: highDegreeThreshold
|
||||||
)
|
)
|
||||||
@@ -1443,14 +1334,11 @@ final class BLEService: NSObject {
|
|||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
// Check if we have an actual BLE connection to this peer
|
// Check if we have an actual BLE connection to this peer
|
||||||
let peripheralUUID = peerToPeripheralUUID[peerID]
|
let peripheralUUID = peerToPeripheralUUID[peerID]
|
||||||
let hasPeripheralConnection = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true
|
_ = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true // hasPeripheralConnection
|
||||||
|
|
||||||
// Check if this peer is subscribed to us as a central
|
// Check if this peer is subscribed to us as a central
|
||||||
// Note: We can't identify which specific central is which peer without additional mapping
|
// Note: We can't identify which specific central is which peer without additional mapping
|
||||||
let hasCentralSubscription = centralToPeerID.values.contains(peerID)
|
_ = !subscribedCentrals.isEmpty // hasCentralSubscription
|
||||||
|
|
||||||
// Direct announces arrive with full TTL (no prior hop)
|
|
||||||
let isDirectAnnounce = (packet.ttl == messageTTL)
|
|
||||||
|
|
||||||
// Check if we already have this peer (might be reconnecting)
|
// Check if we already have this peer (might be reconnecting)
|
||||||
let existingPeer = peers[peerID]
|
let existingPeer = peers[peerID]
|
||||||
@@ -1488,7 +1376,7 @@ final class BLEService: NSObject {
|
|||||||
peers[peerID] = PeerInfo(
|
peers[peerID] = PeerInfo(
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
nickname: announcement.nickname,
|
nickname: announcement.nickname,
|
||||||
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
isConnected: true,
|
||||||
noisePublicKey: announcement.noisePublicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
signingPublicKey: announcement.signingPublicKey,
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
@@ -1499,7 +1387,7 @@ final class BLEService: NSObject {
|
|||||||
peers[peerID] = PeerInfo(
|
peers[peerID] = PeerInfo(
|
||||||
id: peerID,
|
id: peerID,
|
||||||
nickname: announcement.nickname,
|
nickname: announcement.nickname,
|
||||||
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
isConnected: true,
|
||||||
noisePublicKey: announcement.noisePublicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
signingPublicKey: announcement.signingPublicKey,
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
@@ -1507,52 +1395,16 @@ final class BLEService: NSObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log connection status only for direct connectivity changes; debounce to reduce spam
|
// Log connection status
|
||||||
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
|
if existingPeer == nil {
|
||||||
let now = Date()
|
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug)
|
||||||
if existingPeer == nil {
|
} else if wasDisconnected {
|
||||||
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug)
|
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug)
|
||||||
} else if wasDisconnected {
|
} else if existingPeer?.nickname != announcement.nickname {
|
||||||
// Debounce 'reconnected' logs within short window
|
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug)
|
||||||
if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {
|
|
||||||
// Skip duplicate log
|
|
||||||
} else {
|
|
||||||
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug)
|
|
||||||
lastReconnectLogAt[peerID] = now
|
|
||||||
}
|
|
||||||
} else if existingPeer?.nickname != announcement.nickname {
|
|
||||||
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist cryptographic identity and signing key for robust offline verification
|
|
||||||
do {
|
|
||||||
// Derive fingerprint from Noise public key
|
|
||||||
let hash = SHA256.hash(data: announcement.noisePublicKey)
|
|
||||||
let fingerprint = hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
SecureIdentityStateManager.shared.upsertCryptographicIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
noisePublicKey: announcement.noisePublicKey,
|
|
||||||
signingPublicKey: announcement.signingPublicKey,
|
|
||||||
claimedNickname: announcement.nickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record this announce for lightweight rebroadcast buffer (exclude self)
|
|
||||||
if peerID != myPeerID {
|
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.recentAnnounceBySender[peerID] = packet
|
|
||||||
if !self.recentAnnounceOrder.contains(peerID) { self.recentAnnounceOrder.append(peerID) }
|
|
||||||
// Trim to cap, oldest first
|
|
||||||
while self.recentAnnounceOrder.count > self.recentAnnounceBufferCap {
|
|
||||||
let victim = self.recentAnnounceOrder.removeFirst()
|
|
||||||
self.recentAnnounceBySender.removeValue(forKey: victim)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify UI on main thread
|
// Notify UI on main thread
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -1560,8 +1412,8 @@ final class BLEService: NSObject {
|
|||||||
// Get current peer list (after addition)
|
// Get current peer list (after addition)
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
||||||
|
|
||||||
// Only notify of connection for new or reconnected peers when it is a direct announce
|
// Only notify of connection for new or reconnected peers
|
||||||
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
|
if isNewPeer || isReconnectedPeer {
|
||||||
self.delegate?.didConnectToPeer(peerID)
|
self.delegate?.didConnectToPeer(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1581,14 +1433,6 @@ final class BLEService: NSObject {
|
|||||||
// Force send to ensure the peer receives our announce
|
// Force send to ensure the peer receives our announce
|
||||||
sendAnnounce(forceSend: true)
|
sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
|
|
||||||
if isNewPeer {
|
|
||||||
let delay = Double.random(in: 0.3...0.6)
|
|
||||||
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
||||||
self?.sendAnnounce(forceSend: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mention parsing moved to ChatViewModel
|
// Mention parsing moved to ChatViewModel
|
||||||
@@ -1596,41 +1440,9 @@ final class BLEService: NSObject {
|
|||||||
private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
|
private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
|
||||||
// Ignore self-origin public messages that may be seen again via relay
|
// Ignore self-origin public messages that may be seen again via relay
|
||||||
if peerID == myPeerID { return }
|
if peerID == myPeerID { return }
|
||||||
|
|
||||||
var accepted = false
|
// Enforce: only accept public messages from verified peers we know
|
||||||
var senderNickname: String = ""
|
guard let info = peers[peerID], info.isVerifiedNickname else {
|
||||||
|
|
||||||
if let info = peers[peerID], info.isVerifiedNickname {
|
|
||||||
// Known verified peer path
|
|
||||||
accepted = true
|
|
||||||
senderNickname = info.nickname
|
|
||||||
// Handle nickname collisions
|
|
||||||
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname)
|
|
||||||
if hasCollision {
|
|
||||||
senderNickname += "#" + String(peerID.prefix(4))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
|
|
||||||
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
|
|
||||||
// Find candidate identities by peerID prefix (16 hex)
|
|
||||||
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
|
||||||
for candidate in candidates {
|
|
||||||
if let signingKey = candidate.signingPublicKey,
|
|
||||||
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
|
|
||||||
accepted = true
|
|
||||||
// Prefer persisted social petname or claimed nickname
|
|
||||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: candidate.fingerprint) {
|
|
||||||
senderNickname = social.localPetname ?? social.claimedNickname
|
|
||||||
} else {
|
|
||||||
senderNickname = "anon" + String(peerID.prefix(4))
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard accepted else {
|
|
||||||
SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))…", category: SecureLogger.security, level: .warning)
|
SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))…", category: SecureLogger.security, level: .warning)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1639,17 +1451,17 @@ final class BLEService: NSObject {
|
|||||||
SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error)
|
SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Determine if we have a direct link to the sender
|
|
||||||
let hasDirectLink: Bool = collectionsQueue.sync {
|
// Resolve display nickname; if collisions exist, append short peerID suffix
|
||||||
let perUUID = peerToPeripheralUUID[peerID]
|
var senderNickname = info.nickname
|
||||||
let perConnected = perUUID != nil && peripherals[perUUID!]?.isConnected == true
|
// Treat a collision if another connected peer shares the nickname OR our own nickname matches
|
||||||
let hasCentral = centralToPeerID.values.contains(peerID)
|
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname)
|
||||||
return perConnected || hasCentral
|
if hasCollision {
|
||||||
|
senderNickname += "#" + String(peerID.prefix(4))
|
||||||
}
|
}
|
||||||
|
|
||||||
let pathTag = hasDirectLink ? "direct" : "mesh"
|
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug)
|
||||||
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug)
|
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
|
||||||
@@ -1847,64 +1659,31 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||||
// Create typed payload: [type byte] + [message ID]
|
// Send encrypted delivery ACK
|
||||||
var payload = Data([NoisePayloadType.delivered.rawValue])
|
guard noiseService.hasSession(with: peerID) else {
|
||||||
payload.append(contentsOf: messageID.utf8)
|
SecureLogger.log("Cannot send ACK - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning)
|
||||||
|
return
|
||||||
if noiseService.hasEstablishedSession(with: peerID) {
|
|
||||||
do {
|
|
||||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
|
||||||
let packet = BitchatPacket(
|
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
|
||||||
recipientID: Data(hexString: peerID),
|
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
||||||
payload: encrypted,
|
|
||||||
signature: nil,
|
|
||||||
ttl: messageTTL
|
|
||||||
)
|
|
||||||
broadcastPacket(packet)
|
|
||||||
} catch {
|
|
||||||
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Queue for after handshake and initiate if needed
|
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
|
|
||||||
}
|
|
||||||
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
|
|
||||||
SecureLogger.log("🕒 Queued DELIVERED ack for \(peerID) until handshake completes",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Create ACK payload: [type byte] + [message ID]
|
||||||
private func sendPendingNoisePayloadsAfterHandshake(for peerID: String) {
|
var ackPayload = Data([NoisePayloadType.delivered.rawValue])
|
||||||
let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
|
ackPayload.append(contentsOf: messageID.utf8)
|
||||||
let list = pendingNoisePayloadsAfterHandshake[peerID] ?? []
|
|
||||||
pendingNoisePayloadsAfterHandshake.removeValue(forKey: peerID)
|
do {
|
||||||
return list
|
let encrypted = try noiseService.encrypt(ackPayload, for: peerID)
|
||||||
}
|
let packet = BitchatPacket(
|
||||||
guard !payloads.isEmpty else { return }
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
SecureLogger.log("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake",
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||||
category: SecureLogger.session, level: .debug)
|
recipientID: Data(hexString: peerID),
|
||||||
for payload in payloads {
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
do {
|
payload: encrypted,
|
||||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
signature: nil,
|
||||||
let packet = BitchatPacket(
|
ttl: messageTTL
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
)
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
broadcastPacket(packet)
|
||||||
recipientID: Data(hexString: peerID),
|
// Delivery ACK sent
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
} catch {
|
||||||
payload: encrypted,
|
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error)
|
||||||
signature: nil,
|
|
||||||
ttl: messageTTL
|
|
||||||
)
|
|
||||||
broadcastPacket(packet)
|
|
||||||
} catch {
|
|
||||||
SecureLogger.log("❌ Failed to send pending noise payload to \(peerID): \(error)",
|
|
||||||
category: SecureLogger.noise, level: .error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1917,18 +1696,6 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
|
|
||||||
private func notifyPeerDisconnectedDebounced(_ peerID: String) {
|
|
||||||
let now = Date()
|
|
||||||
let last = recentDisconnectNotifies[peerID]
|
|
||||||
if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds {
|
|
||||||
delegate?.didDisconnectFromPeer(peerID)
|
|
||||||
recentDisconnectNotifies[peerID] = now
|
|
||||||
} else {
|
|
||||||
// Suppressed duplicate disconnect notification
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: Publish peer snapshots to subscribers and notify Transport delegates
|
// NEW: Publish peer snapshots to subscribers and notify Transport delegates
|
||||||
private func publishFullPeerData() {
|
private func publishFullPeerData() {
|
||||||
@@ -1981,16 +1748,6 @@ final class BLEService: NSObject {
|
|||||||
let target = base + Double.random(in: -jitter...jitter)
|
let target = base + Double.random(in: -jitter...jitter)
|
||||||
if elapsed >= target { sendAnnounce(forceSend: true) }
|
if elapsed >= target { sendAnnounce(forceSend: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activity-driven quick-announce: if we've seen any packet in last 5s and it has
|
|
||||||
// been >=10s since the last announce, send a presence nudge.
|
|
||||||
let recentSeen = collectionsQueue.sync { () -> Bool in
|
|
||||||
let cutoff = now.addingTimeInterval(-5.0)
|
|
||||||
return recentPacketTimestamps.contains(where: { $0 >= cutoff })
|
|
||||||
}
|
|
||||||
if recentSeen && elapsed >= 10.0 {
|
|
||||||
sendAnnounce(forceSend: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have no peers, ensure we're scanning and advertising
|
// If we have no peers, ensure we're scanning and advertising
|
||||||
if peers.isEmpty {
|
if peers.isEmpty {
|
||||||
@@ -2004,18 +1761,15 @@ final class BLEService: NSObject {
|
|||||||
updateScanningDutyCycle(connectedCount: connectedCount)
|
updateScanningDutyCycle(connectedCount: connectedCount)
|
||||||
updateRSSIThreshold(connectedCount: connectedCount)
|
updateRSSIThreshold(connectedCount: connectedCount)
|
||||||
|
|
||||||
// Check peer connectivity every cycle for snappier UI updates
|
// Every 20 seconds (2 cycles): Check peer connectivity
|
||||||
checkPeerConnectivity()
|
if maintenanceCounter % 2 == 0 {
|
||||||
|
checkPeerConnectivity()
|
||||||
|
}
|
||||||
|
|
||||||
// Every 30 seconds (3 cycles): Cleanup
|
// Every 30 seconds (3 cycles): Cleanup
|
||||||
if maintenanceCounter % 3 == 0 {
|
if maintenanceCounter % 3 == 0 {
|
||||||
performCleanup()
|
performCleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to flush any spooled directed messages periodically (~every 5 seconds)
|
|
||||||
if maintenanceCounter % 2 == 1 {
|
|
||||||
flushDirectedSpool()
|
|
||||||
}
|
|
||||||
|
|
||||||
// No rotating alias: nothing to refresh
|
// No rotating alias: nothing to refresh
|
||||||
|
|
||||||
@@ -2029,39 +1783,28 @@ final class BLEService: NSObject {
|
|||||||
let now = Date()
|
let now = Date()
|
||||||
var disconnectedPeers: [String] = []
|
var disconnectedPeers: [String] = []
|
||||||
|
|
||||||
var removedOfflineCount = 0
|
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
for (peerID, peer) in peers {
|
for (peerID, peer) in peers {
|
||||||
let age = now.timeIntervalSince(peer.lastSeen)
|
if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > TransportConfig.blePeerInactivityTimeoutSeconds {
|
||||||
let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
|
||||||
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
|
|
||||||
// Check if we still have an active BLE connection to this peer
|
// Check if we still have an active BLE connection to this peer
|
||||||
let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil &&
|
let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil &&
|
||||||
peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true
|
peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true
|
||||||
let hasCentralConnection = centralToPeerID.values.contains(peerID)
|
let hasCentralConnection = centralToPeerID.values.contains(peerID)
|
||||||
|
|
||||||
// If direct link is gone, mark as not connected (retain entry for reachability)
|
// Only remove if we don't have an active BLE connection
|
||||||
if !hasPeripheralConnection && !hasCentralConnection {
|
if !hasPeripheralConnection && !hasCentralConnection {
|
||||||
var updated = peer
|
// Remove the peer completely (they'll be re-added when they reconnect)
|
||||||
updated.isConnected = false
|
SecureLogger.log("⏱️ Peer timed out (no packets for 20s): \(peerID) (\(peer.nickname))",
|
||||||
peers[peerID] = updated
|
category: SecureLogger.session, level: .debug)
|
||||||
disconnectedPeers.append(peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Cleanup: remove peers that are not connected and past reachability retention
|
|
||||||
if !peer.isConnected {
|
|
||||||
if age > retention {
|
|
||||||
SecureLogger.log("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
|
||||||
peers.removeValue(forKey: peerID)
|
peers.removeValue(forKey: peerID)
|
||||||
removedOfflineCount += 1
|
disconnectedPeers.append(peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update UI if there were direct disconnections or offline removals
|
// Update UI if any peers were disconnected
|
||||||
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
|
if !disconnectedPeers.isEmpty {
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
@@ -2071,8 +1814,6 @@ final class BLEService: NSObject {
|
|||||||
for peerID in disconnectedPeers {
|
for peerID in disconnectedPeers {
|
||||||
self.delegate?.didDisconnectFromPeer(peerID)
|
self.delegate?.didDisconnectFromPeer(peerID)
|
||||||
}
|
}
|
||||||
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
|
|
||||||
self.publishFullPeerData()
|
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2116,15 +1857,6 @@ final class BLEService: NSObject {
|
|||||||
if !self.ingressByMessageID.isEmpty {
|
if !self.ingressByMessageID.isEmpty {
|
||||||
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
||||||
}
|
}
|
||||||
// Clean expired directed spooled items
|
|
||||||
if !self.pendingDirectedRelays.isEmpty {
|
|
||||||
var cleaned: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
|
|
||||||
for (recipient, dict) in self.pendingDirectedRelays {
|
|
||||||
let pruned = dict.filter { now.timeIntervalSince($0.value.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds }
|
|
||||||
if !pruned.isEmpty { cleaned[recipient] = pruned }
|
|
||||||
}
|
|
||||||
self.pendingDirectedRelays = cleaned
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2136,13 +1868,7 @@ final class BLEService: NSObject {
|
|||||||
#else
|
#else
|
||||||
let active = true
|
let active = true
|
||||||
#endif
|
#endif
|
||||||
// Force full-time scanning if we have very few neighbors or very recent traffic
|
let shouldDuty = dutyEnabled && active && connectedCount > 0
|
||||||
let hasRecentTraffic: Bool = collectionsQueue.sync {
|
|
||||||
let cutoff = Date().addingTimeInterval(-TransportConfig.bleRecentTrafficForceScanSeconds)
|
|
||||||
return recentPacketTimestamps.contains(where: { $0 >= cutoff })
|
|
||||||
}
|
|
||||||
let forceScanOn = (connectedCount <= 2) || hasRecentTraffic
|
|
||||||
let shouldDuty = dutyEnabled && active && connectedCount > 0 && !forceScanOn
|
|
||||||
if shouldDuty {
|
if shouldDuty {
|
||||||
if scanDutyTimer == nil {
|
if scanDutyTimer == nil {
|
||||||
// Start timer to toggle scanning on/off
|
// Start timer to toggle scanning on/off
|
||||||
@@ -2411,11 +2137,6 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
|||||||
|
|
||||||
SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")",
|
SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
|
|
||||||
if error != nil {
|
|
||||||
recentConnectTimeouts[peripheralID] = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up references
|
// Clean up references
|
||||||
peripherals.removeValue(forKey: peripheralID)
|
peripherals.removeValue(forKey: peripheralID)
|
||||||
@@ -2424,12 +2145,9 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
|||||||
if let peerID = peerID {
|
if let peerID = peerID {
|
||||||
peerToPeripheralUUID.removeValue(forKey: peerID)
|
peerToPeripheralUUID.removeValue(forKey: peerID)
|
||||||
|
|
||||||
// Do not remove peer; mark as not connected but retain for reachability
|
// Remove peer completely (they'll be re-added when they reconnect and announce)
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
_ = collectionsQueue.sync(flags: .barrier) {
|
||||||
if var info = peers[peerID] {
|
peers.removeValue(forKey: peerID)
|
||||||
info.isConnected = false
|
|
||||||
peers[peerID] = info
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2444,7 +2162,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
|||||||
// Attempt to fill freed slot from queue
|
// Attempt to fill freed slot from queue
|
||||||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||||||
|
|
||||||
// Notify delegate about disconnection on main thread (direct link dropped)
|
// Notify delegate about disconnection on main thread
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
@@ -2452,7 +2170,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
|||||||
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
||||||
|
|
||||||
if let peerID = peerID {
|
if let peerID = peerID {
|
||||||
self.notifyPeerDisconnectedDebounced(peerID)
|
self.delegate?.didDisconnectFromPeer(peerID)
|
||||||
}
|
}
|
||||||
self.publishFullPeerData()
|
self.publishFullPeerData()
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
@@ -2504,18 +2222,6 @@ extension BLEService {
|
|||||||
guard candidate.isConnectable else { return }
|
guard candidate.isConnectable else { return }
|
||||||
let peripheral = candidate.peripheral
|
let peripheral = candidate.peripheral
|
||||||
let peripheralID = peripheral.identifier.uuidString
|
let peripheralID = peripheral.identifier.uuidString
|
||||||
// Weak-link cooldown: if we recently timed out and RSSI is very weak, delay retries
|
|
||||||
if let lastTO = recentConnectTimeouts[peripheralID] {
|
|
||||||
let elapsed = Date().timeIntervalSince(lastTO)
|
|
||||||
if elapsed < TransportConfig.bleWeakLinkCooldownSeconds && candidate.rssi <= TransportConfig.bleWeakLinkRSSICutoff {
|
|
||||||
// Requeue the candidate and try again later
|
|
||||||
connectionCandidates.append(candidate)
|
|
||||||
let remaining = TransportConfig.bleWeakLinkCooldownSeconds - elapsed
|
|
||||||
let delay = min(max(2.0, remaining), 15.0)
|
|
||||||
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true {
|
if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true {
|
||||||
// Already in progress; skip
|
// Already in progress; skip
|
||||||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||||||
@@ -2649,10 +2355,6 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
// Send announce after subscription is confirmed (force send for new connection)
|
// Send announce after subscription is confirmed (force send for new connection)
|
||||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
|
||||||
self?.sendAnnounce(forceSend: true)
|
self?.sendAnnounce(forceSend: true)
|
||||||
// Try flushing any spooled directed packets now that we have a link
|
|
||||||
self?.flushDirectedSpool()
|
|
||||||
// Rebroadcast a couple of recent announces to seed the new link
|
|
||||||
self?.rebroadcastRecentAnnounces()
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.log("⚠️ Characteristic does not support notifications", category: SecureLogger.session, level: .warning)
|
SecureLogger.log("⚠️ Characteristic does not support notifications", category: SecureLogger.session, level: .warning)
|
||||||
@@ -2815,10 +2517,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
// Send announce to the newly subscribed central after a small delay to avoid overwhelming
|
// Send announce to the newly subscribed central after a small delay to avoid overwhelming
|
||||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
|
||||||
self?.sendAnnounce(forceSend: true)
|
self?.sendAnnounce(forceSend: true)
|
||||||
// Flush any spooled directed packets now that we have a central subscribed
|
|
||||||
self?.flushDirectedSpool()
|
|
||||||
// Rebroadcast a couple of recent announces to seed the new link
|
|
||||||
self?.rebroadcastRecentAnnounces()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2835,12 +2533,9 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
// Find and disconnect the peer associated with this central
|
// Find and disconnect the peer associated with this central
|
||||||
let centralUUID = central.identifier.uuidString
|
let centralUUID = central.identifier.uuidString
|
||||||
if let peerID = centralToPeerID[centralUUID] {
|
if let peerID = centralToPeerID[centralUUID] {
|
||||||
// Mark peer as not connected; retain for reachability
|
// Remove peer completely (they'll be re-added when they reconnect)
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
_ = collectionsQueue.sync(flags: .barrier) {
|
||||||
if var info = peers[peerID] {
|
peers.removeValue(forKey: peerID)
|
||||||
info.isConnected = false
|
|
||||||
peers[peerID] = info
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up mappings
|
// Clean up mappings
|
||||||
@@ -2853,9 +2548,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
// Get current peer list (after removal)
|
// Get current peer list (after removal)
|
||||||
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
||||||
|
|
||||||
self.notifyPeerDisconnectedDebounced(peerID)
|
self.delegate?.didDisconnectFromPeer(peerID)
|
||||||
// Publish snapshots so UnifiedPeerService can refresh icons promptly
|
|
||||||
self.publishFullPeerData()
|
|
||||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
let hasMesh = mesh.isPeerConnected(peerID)
|
||||||
if reachableMesh {
|
let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID)
|
||||||
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
if hasMesh && hasEstablished {
|
||||||
|
SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
// BLEService will initiate a handshake if needed and queue the message
|
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
@@ -57,9 +57,9 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
// Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||||
SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
mesh.sendReadReceipt(receipt, to: peerID)
|
mesh.sendReadReceipt(receipt, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
@@ -70,9 +70,7 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||||
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
|
||||||
category: SecureLogger.session, level: .debug)
|
|
||||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
@@ -103,7 +101,7 @@ final class MessageRouter {
|
|||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
for (content, nickname, messageID) in queued {
|
for (content, nickname, messageID) in queued {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerConnected(peerID) {
|
||||||
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ final class NostrTransport: Transport {
|
|||||||
func emergencyDisconnectAll() { /* no-op */ }
|
func emergencyDisconnectAll() { /* no-op */ }
|
||||||
|
|
||||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
func isPeerConnected(_ peerID: String) -> Bool { false }
|
||||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
|
||||||
func peerNickname(peerID: String) -> String? { nil }
|
func peerNickname(peerID: String) -> String? { nil }
|
||||||
func getPeerNicknames() -> [String : String] { [:] }
|
func getPeerNicknames() -> [String : String] { [:] }
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ struct RelayController {
|
|||||||
isDirectedEncrypted: Bool,
|
isDirectedEncrypted: Bool,
|
||||||
isDirectedFragment: Bool,
|
isDirectedFragment: Bool,
|
||||||
isHandshake: Bool,
|
isHandshake: Bool,
|
||||||
isAnnounce: Bool,
|
|
||||||
degree: Int,
|
degree: Int,
|
||||||
highDegreeThreshold: Int) -> RelayDecision {
|
highDegreeThreshold: Int) -> RelayDecision {
|
||||||
// Suppress obvious non-relays
|
// Suppress obvious non-relays
|
||||||
@@ -26,8 +25,7 @@ struct RelayController {
|
|||||||
// Always relay with no TTL cap for these types
|
// Always relay with no TTL cap for these types
|
||||||
let newTTL = (ttl &- 1)
|
let newTTL = (ttl &- 1)
|
||||||
// Slight jitter to desynchronize without adding too much latency
|
// Slight jitter to desynchronize without adding too much latency
|
||||||
// Tighter for faster multi-hop handshakes and directed DMs
|
let delayRange: ClosedRange<Int> = isHandshake ? 20...60 : 40...120
|
||||||
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
|
||||||
let delayMs = Int.random(in: delayRange)
|
let delayMs = Int.random(in: delayRange)
|
||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
@@ -44,22 +42,15 @@ struct RelayController {
|
|||||||
let prob = baseProb
|
let prob = baseProb
|
||||||
let shouldRelay = Double.random(in: 0...1) <= prob
|
let shouldRelay = Double.random(in: 0...1) <= prob
|
||||||
|
|
||||||
// TTL clamping for broadcast
|
// TTL clamping in dense graphs (only for broadcast)
|
||||||
// - Dense graphs: keep very low to avoid floods
|
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
|
||||||
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
|
|
||||||
// - Announces in sparse graphs get a bit more headroom
|
|
||||||
let ttlCap: UInt8 = {
|
|
||||||
if degree >= highDegreeThreshold { return 3 }
|
|
||||||
return isAnnounce ? 7 : 6
|
|
||||||
}()
|
|
||||||
let clamped = max(1, min(ttl, ttlCap))
|
let clamped = max(1, min(ttl, ttlCap))
|
||||||
let newTTL = clamped &- 1
|
let newTTL = clamped &- 1
|
||||||
|
|
||||||
// Wider jitter window to allow duplicate suppression to win more often
|
// Wider jitter window to allow duplicate suppression to win more often
|
||||||
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
|
||||||
let delayMs: Int
|
let delayMs: Int
|
||||||
switch degree {
|
switch degree {
|
||||||
case 0...2: delayMs = Int.random(in: 10...40)
|
case 0...2: delayMs = Int.random(in: 40...100)
|
||||||
case 3...5: delayMs = Int.random(in: 60...150)
|
case 3...5: delayMs = Int.random(in: 60...150)
|
||||||
case 6...9: delayMs = Int.random(in: 80...180)
|
case 6...9: delayMs = Int.random(in: 80...180)
|
||||||
default: delayMs = Int.random(in: 100...220)
|
default: delayMs = Int.random(in: 100...220)
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ protocol Transport: AnyObject {
|
|||||||
|
|
||||||
// Connectivity and peers
|
// Connectivity and peers
|
||||||
func isPeerConnected(_ peerID: String) -> Bool
|
func isPeerConnected(_ peerID: String) -> Bool
|
||||||
func isPeerReachable(_ peerID: String) -> Bool
|
|
||||||
func peerNickname(peerID: String) -> String?
|
func peerNickname(peerID: String) -> String?
|
||||||
func getPeerNicknames() -> [String: String]
|
func getPeerNicknames() -> [String: String]
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ enum TransportConfig {
|
|||||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||||
|
|
||||||
// BLE maintenance & thresholds
|
// BLE maintenance & thresholds
|
||||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
static let bleMaintenanceInterval: TimeInterval = 10.0
|
||||||
static let bleMaintenanceLeewaySeconds: Int = 1
|
static let bleMaintenanceLeewaySeconds: Int = 1
|
||||||
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
|
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
|
||||||
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
|
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
|
||||||
@@ -71,32 +71,23 @@ enum TransportConfig {
|
|||||||
static let bleRSSIIsolatedRelaxed: Int = -92
|
static let bleRSSIIsolatedRelaxed: Int = -92
|
||||||
static let bleRSSIConnectedThreshold: Int = -85
|
static let bleRSSIConnectedThreshold: Int = -85
|
||||||
static let bleRSSIHighTimeoutThreshold: Int = -80
|
static let bleRSSIHighTimeoutThreshold: Int = -80
|
||||||
// How long without seeing traffic before we sanity-check the direct link
|
static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0
|
||||||
// Lowered to make connected→reachable icon changes react faster when walking out of range
|
|
||||||
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
|
|
||||||
// How long to retain a peer as "reachable" (not directly connected) since lastSeen
|
|
||||||
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites
|
|
||||||
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified
|
|
||||||
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
|
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
|
||||||
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
|
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
|
||||||
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
|
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
|
||||||
static let bleRecentPacketWindowSeconds: TimeInterval = 30.0
|
static let bleRecentPacketWindowSeconds: TimeInterval = 30.0
|
||||||
static let bleRecentPacketWindowMaxCount: Int = 100
|
static let bleRecentPacketWindowMaxCount: Int = 100
|
||||||
// Keep scanning fully ON when we saw traffic very recently
|
|
||||||
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
|
||||||
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
||||||
static let bleExpectedWritePerFragmentMs: Int = 8
|
static let bleExpectedWritePerFragmentMs: Int = 8
|
||||||
static let bleExpectedWriteMaxMs: Int = 2000
|
static let bleExpectedWriteMaxMs: Int = 2000
|
||||||
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
static let bleFragmentSpacingMs: Int = 6
|
||||||
static let bleFragmentSpacingMs: Int = 5
|
static let bleAnnounceIntervalSeconds: TimeInterval = 10.0
|
||||||
static let bleFragmentSpacingDirectedMs: Int = 4
|
|
||||||
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
|
||||||
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
||||||
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
||||||
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 30.0
|
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 90.0
|
||||||
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 15.0
|
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 45.0
|
||||||
static let bleConnectedAnnounceJitterDense: TimeInterval = 8.0
|
static let bleConnectedAnnounceJitterDense: TimeInterval = 20.0
|
||||||
static let bleConnectedAnnounceJitterSparse: TimeInterval = 4.0
|
static let bleConnectedAnnounceJitterSparse: TimeInterval = 7.5
|
||||||
|
|
||||||
// Location
|
// Location
|
||||||
static let locationDistanceFilterMeters: Double = 1000
|
static let locationDistanceFilterMeters: Double = 1000
|
||||||
@@ -135,24 +126,12 @@ enum TransportConfig {
|
|||||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||||
|
|
||||||
// BLE operational delays
|
// BLE operational delays
|
||||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
static let bleInitialAnnounceDelaySeconds: TimeInterval = 2.0
|
||||||
static let bleConnectTimeoutSeconds: TimeInterval = 8.0
|
static let bleConnectTimeoutSeconds: TimeInterval = 8.0
|
||||||
static let bleRestartScanDelaySeconds: TimeInterval = 0.1
|
static let bleRestartScanDelaySeconds: TimeInterval = 0.1
|
||||||
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.05
|
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.1
|
||||||
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
||||||
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.2
|
||||||
|
|
||||||
// Store-and-forward for directed packets at relays
|
|
||||||
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
|
||||||
|
|
||||||
// Log/UI debounce windows
|
|
||||||
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
|
|
||||||
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
|
|
||||||
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
|
|
||||||
|
|
||||||
// Weak-link cooldown after connection timeouts
|
|
||||||
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
|
|
||||||
static let bleWeakLinkRSSICutoff: Int = -90
|
|
||||||
|
|
||||||
// Content hashing / formatting
|
// Content hashing / formatting
|
||||||
static let contentKeyPrefixLength: Int = 256
|
static let contentKeyPrefixLength: Int = 256
|
||||||
|
|||||||
@@ -68,28 +68,24 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
private func updatePeers() {
|
private func updatePeers() {
|
||||||
let meshPeers = meshService.currentPeerSnapshots()
|
let meshPeers = meshService.currentPeerSnapshots()
|
||||||
// If we have no direct links at all, peers should not be marked reachable
|
|
||||||
// "Reachable" means mesh-attached via at least one live link.
|
|
||||||
let hasAnyConnected = meshPeers.contains { $0.isConnected }
|
|
||||||
let favorites = favoritesService.favorites
|
let favorites = favoritesService.favorites
|
||||||
|
|
||||||
var enrichedPeers: [BitchatPeer] = []
|
var enrichedPeers: [BitchatPeer] = []
|
||||||
var connected: Set<String> = []
|
var connected: Set<String> = []
|
||||||
var addedPeerIDs: Set<String> = []
|
var addedPeerIDs: Set<String> = []
|
||||||
|
|
||||||
// Phase 1: Add all mesh peers (connected and reachable)
|
// Phase 1: Add all connected mesh peers
|
||||||
for peerInfo in meshPeers {
|
for peerInfo in meshPeers where peerInfo.isConnected {
|
||||||
let peerID = peerInfo.id
|
let peerID = peerInfo.id
|
||||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||||
|
|
||||||
let peer = buildPeerFromMesh(
|
let peer = buildPeerFromMesh(
|
||||||
peerInfo: peerInfo,
|
peerInfo: peerInfo,
|
||||||
favorites: favorites,
|
favorites: favorites
|
||||||
meshAttached: hasAnyConnected
|
|
||||||
)
|
)
|
||||||
|
|
||||||
enrichedPeers.append(peer)
|
enrichedPeers.append(peer)
|
||||||
if peer.isConnected { connected.insert(peerID) }
|
connected.insert(peerID)
|
||||||
addedPeerIDs.insert(peerID)
|
addedPeerIDs.insert(peerID)
|
||||||
|
|
||||||
// Update fingerprint cache
|
// Update fingerprint cache
|
||||||
@@ -121,12 +117,14 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// Phase 3: Sort peers
|
// Phase 3: Sort peers
|
||||||
enrichedPeers.sort { lhs, rhs in
|
enrichedPeers.sort { lhs, rhs in
|
||||||
// Connectivity rank: connected > reachable > others
|
// Connected first
|
||||||
func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }
|
if lhs.isConnected != rhs.isConnected {
|
||||||
let lr = rank(lhs), rr = rank(rhs)
|
return lhs.isConnected
|
||||||
if lr != rr { return lr > rr }
|
}
|
||||||
// Then favorites inside same rank
|
// Then favorites
|
||||||
if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }
|
if lhs.isFavorite != rhs.isFavorite {
|
||||||
|
return lhs.isFavorite
|
||||||
|
}
|
||||||
// Finally alphabetical
|
// Finally alphabetical
|
||||||
return lhs.displayName < rhs.displayName
|
return lhs.displayName < rhs.displayName
|
||||||
}
|
}
|
||||||
@@ -147,11 +145,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 5: Filter out offline non-mutual peers and update published properties
|
// Phase 5: Update published properties
|
||||||
let filtered = enrichedPeers.filter { p in
|
self.peers = enrichedPeers
|
||||||
p.isConnected || p.isReachable || p.isMutualFavorite
|
|
||||||
}
|
|
||||||
self.peers = filtered
|
|
||||||
self.connectedPeerIDs = connected
|
self.connectedPeerIDs = connected
|
||||||
self.favorites = favoritesList
|
self.favorites = favoritesList
|
||||||
self.mutualFavorites = mutualsList
|
self.mutualFavorites = mutualsList
|
||||||
@@ -167,26 +162,14 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
private func buildPeerFromMesh(
|
private func buildPeerFromMesh(
|
||||||
peerInfo: TransportPeerSnapshot,
|
peerInfo: TransportPeerSnapshot,
|
||||||
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship],
|
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
|
||||||
meshAttached: Bool
|
|
||||||
) -> BitchatPeer {
|
) -> BitchatPeer {
|
||||||
// Determine reachability based on lastSeen and identity trust
|
|
||||||
let now = Date()
|
|
||||||
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
|
|
||||||
let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false
|
|
||||||
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
|
|
||||||
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
|
||||||
// A peer is reachable if we recently saw them AND we are attached to the mesh
|
|
||||||
let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention
|
|
||||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
|
||||||
|
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
id: peerInfo.id,
|
id: peerInfo.id,
|
||||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||||
nickname: peerInfo.nickname,
|
nickname: peerInfo.nickname,
|
||||||
lastSeen: peerInfo.lastSeen,
|
lastSeen: peerInfo.lastSeen,
|
||||||
isConnected: peerInfo.isConnected,
|
isConnected: true
|
||||||
isReachable: isReachable
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check for favorite status
|
// Check for favorite status
|
||||||
@@ -233,8 +216,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
noisePublicKey: favorite.peerNoisePublicKey,
|
noisePublicKey: favorite.peerNoisePublicKey,
|
||||||
nickname: favorite.peerNickname,
|
nickname: favorite.peerNickname,
|
||||||
lastSeen: favorite.lastUpdated,
|
lastSeen: favorite.lastUpdated,
|
||||||
isConnected: false,
|
isConnected: false
|
||||||
isReachable: false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
peer.favoriteStatus = favorite
|
peer.favoriteStatus = favorite
|
||||||
|
|||||||
@@ -935,13 +935,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if unreadPrivateMessages.contains(noiseKeyHex) {
|
if unreadPrivateMessages.contains(noiseKeyHex) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey
|
|
||||||
if let nostrHex = peer.nostrPublicKey {
|
|
||||||
let convKey = "nostr_" + String(nostrHex.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
|
||||||
if unreadPrivateMessages.contains(convKey) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the peer's nickname to check for temporary Nostr peer IDs
|
// Get the peer's nickname to check for temporary Nostr peer IDs
|
||||||
@@ -1881,7 +1874,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Determine routing method and recipient nickname
|
// Determine routing method and recipient nickname
|
||||||
guard let noiseKey = Data(hexString: peerID) else { return }
|
guard let noiseKey = Data(hexString: peerID) else { return }
|
||||||
let isConnected = meshService.isPeerConnected(peerID)
|
let isConnected = meshService.isPeerConnected(peerID)
|
||||||
let isReachable = meshService.isPeerReachable(peerID)
|
|
||||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||||
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||||
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||||
@@ -1921,8 +1913,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Trigger UI update for sent message
|
// Trigger UI update for sent message
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
|
|
||||||
// Send via appropriate transport (BLE if connected/reachable, else Nostr when possible)
|
// Send via appropriate transport (BLE if connected, else Nostr when possible)
|
||||||
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
|
if isConnected || (isMutualFavorite && hasNostrKey) {
|
||||||
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
|
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
|
||||||
// Optimistically mark as sent for both transports; delivery/read will update subsequently
|
// Optimistically mark as sent for both transports; delivery/read will update subsequently
|
||||||
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||||
@@ -2550,12 +2542,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this originated over Nostr, skip (handled by Nostr code paths)
|
// If we know the original transport, use it for the read receipt
|
||||||
if originalTransport == "nostr" {
|
if originalTransport == "nostr" {
|
||||||
return
|
// Skip read receipts for Nostr messages - unnecessary complexity
|
||||||
|
// The radical simplification plan says to accept occasional loss
|
||||||
|
} else if meshService.peerNickname(peerID: actualPeerID) != nil {
|
||||||
|
// Use mesh for connected peers (default behavior)
|
||||||
|
messageRouter.sendReadReceipt(receipt, to: actualPeerID)
|
||||||
|
} else {
|
||||||
|
// Skip read receipts for offline peers - fire and forget principle
|
||||||
}
|
}
|
||||||
// Use router to decide (mesh if reachable, else Nostr if available)
|
|
||||||
messageRouter.sendReadReceipt(receipt, to: actualPeerID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -1036,8 +1036,9 @@ struct ContentView: View {
|
|||||||
case .mesh:
|
case .mesh:
|
||||||
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
||||||
guard peer.id != viewModel.meshService.myPeerID else { return }
|
guard peer.id != viewModel.meshService.myPeerID else { return }
|
||||||
if peer.isConnected { counts.mesh += 1; counts.others += 1 }
|
let isMeshConnected = peer.isConnected
|
||||||
else if peer.isReachable { counts.others += 1 }
|
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
||||||
|
else if peer.isMutualFavorite { counts.others += 1 }
|
||||||
}
|
}
|
||||||
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||||
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
|
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
|
||||||
@@ -1180,11 +1181,20 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func privateHeaderContent(for privatePeerID: String) -> some View {
|
private func privateHeaderContent(for privatePeerID: String) -> some View {
|
||||||
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
|
// Prefer short (mesh) ID when mesh-connected (radio). Only use full Noise key when not connected (globe).
|
||||||
let headerPeerID: String = {
|
let headerPeerID: String = {
|
||||||
if privatePeerID.count == 64 {
|
if privatePeerID.count == 16 {
|
||||||
// Map stable Noise key to short ID if we know it (even if not directly connected)
|
let isMeshConnected = viewModel.meshService.isPeerConnected(privatePeerID) || viewModel.connectedPeers.contains(privatePeerID)
|
||||||
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
|
if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) {
|
||||||
|
return stable
|
||||||
|
}
|
||||||
|
} else if privatePeerID.count == 64 {
|
||||||
|
// If we have a full Noise key and a corresponding short ID is currently mesh-connected, prefer short ID
|
||||||
|
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||||
|
if viewModel.meshService.isPeerConnected(short) || viewModel.connectedPeers.contains(short) {
|
||||||
|
return short
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return privatePeerID
|
return privatePeerID
|
||||||
}()
|
}()
|
||||||
@@ -1199,29 +1209,10 @@ struct ContentView: View {
|
|||||||
return "#\(ch.geohash)/@\(disp)"
|
return "#\(ch.geohash)/@\(disp)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Try mesh/unified peer display
|
return peer?.displayName ??
|
||||||
if let name = peer?.displayName { return name }
|
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
|
||||||
// Try direct mesh nickname (connected-only)
|
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
"Unknown"
|
||||||
// Try favorite nickname by stable Noise key
|
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
|
||||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
|
||||||
// Fallback: resolve from persisted social identity via fingerprint mapping
|
|
||||||
if headerPeerID.count == 16 {
|
|
||||||
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
|
||||||
if let id = candidates.first,
|
|
||||||
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) {
|
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
|
||||||
}
|
|
||||||
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
|
|
||||||
let fp = keyData.sha256Fingerprint()
|
|
||||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "Unknown"
|
|
||||||
}()
|
}()
|
||||||
let isNostrAvailable: Bool = {
|
let isNostrAvailable: Bool = {
|
||||||
guard let connectionState = peer?.connectionState else {
|
guard let connectionState = peer?.connectionState else {
|
||||||
@@ -1256,12 +1247,6 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.accessibilityLabel("Connected via mesh")
|
.accessibilityLabel("Connected via mesh")
|
||||||
case .meshReachable:
|
|
||||||
// point.3 filled icon for reachable via mesh (not directly connected)
|
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.accessibilityLabel("Reachable via mesh")
|
|
||||||
case .nostrAvailable:
|
case .nostrAvailable:
|
||||||
// Purple globe for Nostr
|
// Purple globe for Nostr
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
@@ -1272,12 +1257,6 @@ struct ContentView: View {
|
|||||||
// Should not happen for PM header, but handle gracefully
|
// Should not happen for PM header, but handle gracefully
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
|
|
||||||
// Fallback: reachable via mesh but not in current peer list
|
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.accessibilityLabel("Reachable via mesh")
|
|
||||||
} else if isNostrAvailable {
|
} else if isNostrAvailable {
|
||||||
// Fallback to Nostr if peer not in list but is mutual favorite
|
// Fallback to Nostr if peer not in list but is mutual favorite
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
@@ -1296,14 +1275,7 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||||
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
||||||
if !privatePeerID.hasPrefix("nostr_") {
|
if !privatePeerID.hasPrefix("nostr_") {
|
||||||
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
|
||||||
let statusPeerID: String = {
|
|
||||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
|
||||||
return short
|
|
||||||
}
|
|
||||||
return headerPeerID
|
|
||||||
}()
|
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
|
|||||||
@@ -41,27 +41,9 @@ struct FingerprintView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Prefer short mesh ID for session/encryption status
|
// Peer info
|
||||||
let statusPeerID: String = {
|
let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "Unknown"
|
||||||
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
let encryptionStatus = viewModel.getEncryptionStatus(for: peerID)
|
||||||
return peerID
|
|
||||||
}()
|
|
||||||
// Resolve a friendly name
|
|
||||||
let peerNickname: String = {
|
|
||||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
|
||||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
|
||||||
let fp = data.sha256Fingerprint()
|
|
||||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
|
||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "Unknown"
|
|
||||||
}()
|
|
||||||
// Accurate encryption state based on short ID session
|
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
@@ -92,7 +74,7 @@ struct FingerprintView: View {
|
|||||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
if let fingerprint = viewModel.getFingerprint(for: peerID) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|||||||
@@ -52,11 +52,6 @@ struct MeshPeerList: View {
|
|||||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if peer.isReachable {
|
|
||||||
// Mesh-reachable (relayed): point.3 icon
|
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
|
||||||
.font(.system(size: 10))
|
|
||||||
.foregroundColor(baseColor)
|
|
||||||
} else if peer.isMutualFavorite {
|
} else if peer.isMutualFavorite {
|
||||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
@@ -115,14 +110,6 @@ struct MeshPeerList: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Unread message indicator for this peer
|
|
||||||
if !isMe, item.hasUnread {
|
|
||||||
Image(systemName: "envelope.fill")
|
|
||||||
.font(.system(size: 10))
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
.help("New messages")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.id) }) {
|
Button(action: { onToggleFavorite(peer.id) }) {
|
||||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||||
|
|||||||
Reference in New Issue
Block a user