From 0ce68bc7624f14e26d1cb1e64ff49cb626a18237 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sat, 6 Sep 2025 12:47:32 +0200 Subject: [PATCH] Perf/optimizations (#563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Perf: reduce hot‑path overhead (logger autoclosure, zero‑copy BinaryProtocol.decode, prealloc encoders) * Compression: revert to zlib per request (compatibility) * Nostr: parse inbound messages off-main, then update state on main; BLE: debounce peer snapshot publishing to reduce churn * Fix: Swift 6 concurrency - avoid capturing self in detached tasks; deliver parsed Nostr messages via MainActor singleton * Fix: move ParsedInbound + parseInboundMessage to file scope (non-isolated) to satisfy Swift 6; update detached tasks to call free function --------- Co-authored-by: jack --- bitchat/Nostr/NostrRelayManager.swift | 179 +++++++++------- bitchat/Protocols/BinaryEncodingUtils.swift | 17 +- bitchat/Protocols/BinaryProtocol.swift | 223 +++++++++----------- bitchat/Protocols/Packets.swift | 3 + bitchat/Services/BLEService.swift | 101 +++++---- bitchat/Utils/MessageDeduplicator.swift | 28 ++- bitchat/Utils/SecureLogger.swift | 8 +- 7 files changed, 297 insertions(+), 262 deletions(-) diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 2f5f66d6..98d1da37 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -51,6 +51,8 @@ class NostrRelayManager: ObservableObject { } private var messageQueue: [PendingSend] = [] private let messageQueueLock = NSLock() + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() // Exponential backoff configuration private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds @@ -64,6 +66,8 @@ class NostrRelayManager: ObservableObject { init() { // Initialize with default relays self.relays = Self.defaultRelays.map { Relay(url: $0) } + // Deterministic JSON shape for outbound requests + self.encoder.outputFormatting = .sortedKeys } /// Connect to all configured relays @@ -170,8 +174,6 @@ class NostrRelayManager: ObservableObject { let req = NostrRequest.subscribe(id: id, filters: [filter]) do { - let encoder = JSONEncoder() - encoder.outputFormatting = .sortedKeys // For consistent output let message = try encoder.encode(req) guard let messageString = String(data: message, encoding: .utf8) else { SecureLogger.log("❌ Failed to encode subscription request", category: SecureLogger.session, level: .error) @@ -221,7 +223,7 @@ class NostrRelayManager: ObservableObject { messageHandlers.removeValue(forKey: id) let req = NostrRequest.close(id: id) - let message = try? JSONEncoder().encode(req) + let message = try? encoder.encode(req) guard let messageData = message, let messageString = String(data: messageData, encoding: .utf8) else { return } @@ -288,14 +290,21 @@ class NostrRelayManager: ObservableObject { case .success(let message): switch message { case .string(let text): - Task { @MainActor in - self.handleMessage(text, from: relayUrl) + // Parse off-main to reduce UI jank, then hop back for state updates + Task.detached(priority: .utility) { + guard let parsed = parseInboundMessage(text) else { return } + await MainActor.run { + NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl) + } } case .data(let data): if let text = String(data: data, encoding: .utf8) { - Task { @MainActor in - self.handleMessage(text, from: relayUrl) - } + Task.detached(priority: .utility) { + guard let parsed = parseInboundMessage(text) else { return } + await MainActor.run { + NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl) + } + } } @unknown default: break @@ -314,79 +323,42 @@ class NostrRelayManager: ObservableObject { } } - private func handleMessage(_ message: String, from relayUrl: String) { - guard let data = message.data(using: .utf8) else { return } - - do { - // Try to decode as an array first - if let array = try JSONSerialization.jsonObject(with: data) as? [Any], - array.count >= 2, - let type = array[0] as? String { - - // Received message from relay - - switch type { - case "EVENT": - if array.count >= 3, - let subId = array[1] as? String, - let eventDict = array[2] as? [String: Any] { - - let event = try NostrEvent(from: eventDict) - - // Only log non-gift-wrap events to reduce noise - if event.kind != 1059 { - SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", - category: SecureLogger.session, level: .debug) - } - - DispatchQueue.main.async { - // Update relay stats - if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { - self.relays[index].messagesReceived += 1 - } - - // Call handler - if let handler = self.messageHandlers[subId] { - handler(event) - } else { - SecureLogger.log("⚠️ No handler for subscription \(subId)", - category: SecureLogger.session, level: .warning) - } - } - } - - case "EOSE": - if array.count >= 2 { - // End of stored events - } - - case "OK": - if array.count >= 3, - let eventId = array[1] as? String, - let success = array[2] as? Bool { - let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given" - if success { - _ = Self.pendingGiftWrapIDs.remove(eventId) - SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", - category: SecureLogger.session, level: .debug) - } else { - let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil - SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", - category: SecureLogger.session, level: isGiftWrap ? .warning : .error) - } - } - - case "NOTICE": - if array.count >= 2 { - // Server notice received - } - - default: - break // Unknown message type - } + // Parsed inbound message type (off-main) + // Note: declared at file scope below to avoid MainActor isolation inside this class + // and keep parsing off the main actor. + + // Handle parsed message on MainActor (state updates and handlers) + private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { + switch parsed { + case .event(let subId, let event): + if event.kind != 1059 { + SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", + category: SecureLogger.session, level: .debug) } - } catch { - SecureLogger.log("Failed to parse Nostr message: \(error)", category: SecureLogger.session, level: .error) + if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { + self.relays[index].messagesReceived += 1 + } + if let handler = self.messageHandlers[subId] { + handler(event) + } else { + SecureLogger.log("⚠️ No handler for subscription \(subId)", + category: SecureLogger.session, level: .warning) + } + case .eose: + // No-op for now + break + case .ok(let eventId, let success, let reason): + if success { + _ = Self.pendingGiftWrapIDs.remove(eventId) + SecureLogger.log("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", + category: SecureLogger.session, level: .debug) + } else { + let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil + SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", + category: SecureLogger.session, level: isGiftWrap ? .warning : .error) + } + case .notice: + break } } @@ -394,8 +366,6 @@ class NostrRelayManager: ObservableObject { let req = NostrRequest.event(event) do { - let encoder = JSONEncoder() - encoder.outputFormatting = .sortedKeys let data = try encoder.encode(req) let message = String(data: data, encoding: .utf8) ?? "" @@ -544,6 +514,51 @@ class NostrRelayManager: ObservableObject { } } +// MARK: - Off-main inbound parsing helpers (file scope, non-isolated) + +private enum ParsedInbound { + case event(subId: String, event: NostrEvent) + case ok(eventId: String, success: Bool, reason: String) + case eose(subscriptionId: String) + case notice(String) +} + +// Off-main JSON parse to avoid UI jank; pure function, not actor-isolated +private func parseInboundMessage(_ message: String) -> ParsedInbound? { + guard let data = message.data(using: .utf8) else { return nil } + do { + if let array = try JSONSerialization.jsonObject(with: data) as? [Any], + array.count >= 2, + let type = array[0] as? String { + switch type { + case "EVENT": + if array.count >= 3, + let subId = array[1] as? String, + let eventDict = array[2] as? [String: Any] { + let event = try NostrEvent(from: eventDict) + return .event(subId: subId, event: event) + } + case "EOSE": + if let subId = array[1] as? String { return .eose(subscriptionId: subId) } + case "OK": + if array.count >= 3, + let eventId = array[1] as? String, + let success = array[2] as? Bool { + let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given" + return .ok(eventId: eventId, success: success, reason: reason) + } + case "NOTICE": + if array.count >= 2, let msg = array[1] as? String { return .notice(msg) } + default: + return nil + } + } + } catch { + // Ignore + } + return nil +} + // MARK: - Nostr Protocol Types enum NostrRequest: Encodable { diff --git a/bitchat/Protocols/BinaryEncodingUtils.swift b/bitchat/Protocols/BinaryEncodingUtils.swift index 575d2079..78b4c361 100644 --- a/bitchat/Protocols/BinaryEncodingUtils.swift +++ b/bitchat/Protocols/BinaryEncodingUtils.swift @@ -40,23 +40,23 @@ extension Data { extension Data { // MARK: Writing - mutating func appendUInt8(_ value: UInt8) { + @inlinable mutating func appendUInt8(_ value: UInt8) { self.append(value) } - mutating func appendUInt16(_ value: UInt16) { + @inlinable mutating func appendUInt16(_ value: UInt16) { self.append(UInt8((value >> 8) & 0xFF)) self.append(UInt8(value & 0xFF)) } - mutating func appendUInt32(_ value: UInt32) { + @inlinable mutating func appendUInt32(_ value: UInt32) { self.append(UInt8((value >> 24) & 0xFF)) self.append(UInt8((value >> 16) & 0xFF)) self.append(UInt8((value >> 8) & 0xFF)) self.append(UInt8(value & 0xFF)) } - mutating func appendUInt64(_ value: UInt64) { + @inlinable mutating func appendUInt64(_ value: UInt64) { for i in (0..<8).reversed() { self.append(UInt8((value >> (i * 8)) & 0xFF)) } @@ -113,21 +113,21 @@ extension Data { // MARK: Reading - func readUInt8(at offset: inout Int) -> UInt8? { + @inlinable func readUInt8(at offset: inout Int) -> UInt8? { guard offset >= 0 && offset < self.count else { return nil } let value = self[offset] offset += 1 return value } - func readUInt16(at offset: inout Int) -> UInt16? { + @inlinable func readUInt16(at offset: inout Int) -> UInt16? { guard offset + 2 <= self.count else { return nil } let value = UInt16(self[offset]) << 8 | UInt16(self[offset + 1]) offset += 2 return value } - func readUInt32(at offset: inout Int) -> UInt32? { + @inlinable func readUInt32(at offset: inout Int) -> UInt32? { guard offset + 4 <= self.count else { return nil } let value = UInt32(self[offset]) << 24 | UInt32(self[offset + 1]) << 16 | @@ -137,7 +137,7 @@ extension Data { return value } - func readUInt64(at offset: inout Int) -> UInt64? { + @inlinable func readUInt64(at offset: inout Int) -> UInt64? { guard offset + 8 <= self.count else { return nil } var value: UInt64 = 0 for i in 0..<8 { @@ -220,4 +220,3 @@ extension Data { return data } } - diff --git a/bitchat/Protocols/BinaryProtocol.swift b/bitchat/Protocols/BinaryProtocol.swift index d1ef8959..d6a0b37b 100644 --- a/bitchat/Protocols/BinaryProtocol.swift +++ b/bitchat/Protocols/BinaryProtocol.swift @@ -139,6 +139,11 @@ struct BinaryProtocol { } // Header + // Reserve capacity to reduce reallocations. Estimate base size conservatively. + // header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad + let estimatedPayload = payload.count + (isCompressed ? 2 : 0) + let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255 + data.reserveCapacity(estimated) data.append(packet.version) data.append(packet.type) data.append(packet.ttl) @@ -222,135 +227,105 @@ struct BinaryProtocol { // Core decoding implementation used by decode(_:) with and without padding removal private static func decodeCore(_ raw: Data) -> BitchatPacket? { - // Minimum size check: header + senderID - guard raw.count >= headerSize + senderIDSize else { - return nil - } - - // Convert to array for safer indexed access - let dataArray = Array(raw) - var offset = 0 - - // Header parsing with bounds checks - guard offset < dataArray.count else { return nil } - let version = dataArray[offset]; offset += 1 - - // Check if version is 1 (only supported version) - guard version == 1 else { - return nil - } - - guard offset < dataArray.count else { return nil } - let type = dataArray[offset]; offset += 1 - - guard offset < dataArray.count else { return nil } - let ttl = dataArray[offset]; offset += 1 - - // Timestamp - need 8 bytes - guard offset + 8 <= dataArray.count else { return nil } - let timestampData = Data(dataArray[offset..= 2 else { return nil } - - // Check we have enough data for the original size prefix - guard offset + 2 <= dataArray.count else { return nil } - let originalSizeData = Data(dataArray[offset..= 0 && originalSize <= 1048576 else { return nil } // Max 1MB - - // Check we have enough data for the compressed payload - let compressedPayloadSize = Int(payloadLength) - 2 - guard compressedPayloadSize >= 0 && offset + compressedPayloadSize <= dataArray.count else { - return nil + // Minimum size: header + senderID + guard raw.count >= headerSize + senderIDSize else { return nil } + + return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in + guard let base = buf.baseAddress else { return nil } + var offset = 0 + func require(_ n: Int) -> Bool { offset + n <= buf.count } + // Read single byte + func read8() -> UInt8? { + guard require(1) else { return nil } + let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee + offset += 1 + return v } - - let compressedPayload = Data(dataArray[offset.. UInt16? { + guard require(2) else { return nil } + let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self) + let v = (UInt16(p[0]) << 8) | UInt16(p[1]) + offset += 2 + return v } - - // Verify decompressed size matches expected - guard decompressedPayload.count == originalSize else { - return nil + // Copy N bytes into Data + func readData(_ n: Int) -> Data? { + guard require(n) else { return nil } + let ptr = base.advanced(by: offset) + let d = Data(bytes: ptr, count: n) + offset += n + return d } - - payload = decompressedPayload - } else { - // Uncompressed payload - guard Int(payloadLength) >= 0 && offset + Int(payloadLength) <= dataArray.count else { - return nil + + // Version + guard let version = read8(), version == 1 else { return nil } + guard let type = read8() else { return nil } + guard let ttl = read8() else { return nil } + + // Timestamp 8 bytes BE + guard require(8) else { return nil } + var ts: UInt64 = 0 + for _ in 0..<8 { + guard let b = read8() else { return nil } + ts = (ts << 8) | UInt64(b) } - payload = Data(dataArray[offset..= 0 && originalSize <= 1_048_576 else { return nil } + let compSize = Int(payloadLen) - 2 + guard compSize >= 0, let compressed = readData(compSize) else { return nil } + guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize), + decompressed.count == originalSize else { return nil } + payload = decompressed + } else { + guard let p = readData(Int(payloadLen)) else { return nil } + payload = p + } + + // Signature + var signature: Data? = nil + if hasSignature { + signature = readData(signatureSize) + if signature == nil { return nil } + } + + guard offset <= buf.count else { return nil } + + return BitchatPacket( + type: type, + senderID: senderID, + recipientID: recipientID, + timestamp: ts, + payload: payload, + signature: signature, + ttl: ttl + ) } - - // Signature if present - var signature: Data? - if hasSignature { - guard offset + signatureSize <= dataArray.count else { return nil } - signature = Data(dataArray[offset.. Data? { var data = Data() + // Reserve: TLVs for nickname (2 + n), noise key (2 + 32), signing key (2 + 32) + data.reserveCapacity(2 + min(nickname.count, 255) + 2 + noisePublicKey.count + 2 + signingPublicKey.count) // TLV for nickname guard let nicknameData = nickname.data(using: .utf8), nicknameData.count <= 255 else { return nil } @@ -88,6 +90,7 @@ struct PrivateMessagePacket { func encode() -> Data? { var data = Data() + data.reserveCapacity(2 + min(messageID.count, 255) + 2 + min(content.count, 255)) // TLV for messageID guard let messageIDData = messageID.data(using: .utf8), messageIDData.count <= 255 else { return nil } diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index ca5fbdf9..3564beef 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -63,8 +63,9 @@ final class BLEService: NSObject { private let messageDeduplicator = MessageDeduplicator() // 5. Fragment Reassembly (necessary for messages > MTU) - private var incomingFragments: [String: [Int: Data]] = [:] - private var fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)] = [:] + private struct FragmentKey: Hashable { let sender: UInt64; let id: UInt64 } + private var incomingFragments: [FragmentKey: [Int: Data]] = [:] + private var fragmentMetadata: [FragmentKey: (type: UInt8, total: Int, timestamp: Date)] = [:] // Backoff for peripherals that recently timed out connecting private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout @@ -88,6 +89,7 @@ final class BLEService: NSObject { var myPeerID: String = "" var myNickname: String = "anon" private let noiseService = NoiseEncryptionService() + private var myPeerIDData: Data = Data() // MARK: - Advertising Privacy // No Local Name by default for maximum privacy. No rotating alias. @@ -331,6 +333,7 @@ final class BLEService: NSObject { // Derive stable peer ID from Noise static public key fingerprint (first 8 bytes → 16 hex chars) let fingerprint = noiseService.getIdentityFingerprint() // 64 hex chars self.myPeerID = String(fingerprint.prefix(16)) + self.myPeerIDData = Data(hexString: myPeerID) ?? Data() // Set queue key for identification messageQueue.setSpecific(key: messageQueueKey, value: ()) @@ -397,7 +400,7 @@ final class BLEService: NSObject { maintenanceTimer = timer // Publish initial empty state - publishFullPeerData() + requestPeerDataPublish() } func setNickname(_ nickname: String) { @@ -486,7 +489,7 @@ final class BLEService: NSObject { // Send leave message synchronously to ensure delivery let leavePacket = BitchatPacket( type: MessageType.leave.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), + senderID: myPeerIDData, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: Data(), @@ -614,7 +617,7 @@ final class BLEService: NSObject { let encrypted = try noiseService.encrypt(payload, for: peerID) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), + senderID: myPeerIDData, recipientID: Data(hexString: peerID), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, @@ -666,7 +669,7 @@ final class BLEService: NSObject { let encrypted = try noiseService.encrypt(typedPayload, for: peerID) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), + senderID: myPeerIDData, recipientID: Data(hexString: peerID), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, @@ -823,7 +826,7 @@ final class BLEService: NSObject { let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), + senderID: myPeerIDData, recipientID: recipientData, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, @@ -877,7 +880,7 @@ final class BLEService: NSObject { // Send handshake init let packet = BitchatPacket( type: MessageType.noiseHandshake.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), + senderID: myPeerIDData, recipientID: Data(hexString: peerID), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: handshakeData, @@ -927,7 +930,7 @@ final class BLEService: NSObject { let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), + senderID: myPeerIDData, recipientID: Data(hexString: peerID), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, @@ -1270,8 +1273,11 @@ final class BLEService: NSObject { // Minimum header: 8 bytes ID + 2 index + 2 total + 1 type guard packet.payload.count >= 13 else { return } - let senderHex = packet.senderID.hexEncodedString() - let fragmentID = packet.payload[0..<8].map { String(format: "%02x", $0) }.joined() + // Compute compact fragment key (sender: 8 bytes, id: 8 bytes), big-endian + var senderU64: UInt64 = 0 + for b in packet.senderID.prefix(8) { senderU64 = (senderU64 << 8) | UInt64(b) } + var fragU64: UInt64 = 0 + for b in packet.payload.prefix(8) { fragU64 = (fragU64 << 8) | UInt64(b) } // Parse big-endian UInt16 safely without alignment assumptions let idxHi = UInt16(packet.payload[8]) let idxLo = UInt16(packet.payload[9]) @@ -1286,7 +1292,7 @@ final class BLEService: NSObject { guard total > 0 && index >= 0 && index < total else { return } // Store fragment - let key = "\(senderHex):\(fragmentID)" + let key = FragmentKey(sender: senderU64, id: fragU64) if incomingFragments[key] == nil { // Cap in-flight assemblies to prevent memory/battery blowups if incomingFragments.count >= maxInFlightAssemblies { @@ -1458,7 +1464,21 @@ final class BLEService: NSObject { } // Suppress announce logs to reduce noise - + + // Precompute signature verification outside barrier to reduce contention + let existingPeerForVerify = collectionsQueue.sync { peers[peerID] } + var verifiedAnnounce = false + if packet.signature != nil { + verifiedAnnounce = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) + if !verifiedAnnounce { + SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) + } + } + if let existingKey = existingPeerForVerify?.noisePublicKey, existingKey != announcement.noisePublicKey { + SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning) + verifiedAnnounce = false + } + // Track if this is a new or reconnected peer var isNewPeer = false var isReconnectedPeer = false @@ -1483,21 +1503,8 @@ final class BLEService: NSObject { isNewPeer = (existingPeer == nil) isReconnectedPeer = wasDisconnected - // Verify packet signature using the announced signing public key - var verified = false - if packet.signature != nil { - // Verify that the packet was signed by the signing private key corresponding to the announced signing public key - verified = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) - if !verified { - SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) - } - } - - // If existing peer has a different noise public key, do not consider this verified - if let existing = existingPeer, let existingKey = existing.noisePublicKey, existingKey != announcement.noisePublicKey { - SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning) - verified = false - } + // Use precomputed verification result + let verified = verifiedAnnounce // Require verified announce; ignore otherwise (no backward compatibility) if !verified { @@ -1588,7 +1595,7 @@ final class BLEService: NSObject { self.delegate?.didConnectToPeer(peerID) } - self.publishFullPeerData() + self.requestPeerDataPublish() self.delegate?.didUpdatePeerList(currentPeerIDs) } @@ -1689,7 +1696,7 @@ final class BLEService: NSObject { // Send response let responsePacket = BitchatPacket( type: MessageType.noiseHandshake.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), + senderID: myPeerIDData, recipientID: Data(hexString: peerID), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: response, @@ -1845,7 +1852,7 @@ final class BLEService: NSObject { // Create packet with signature using the noise private key let packet = BitchatPacket( type: MessageType.announce.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), + senderID: myPeerIDData, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, @@ -1879,7 +1886,7 @@ final class BLEService: NSObject { let encrypted = try noiseService.encrypt(payload, for: peerID) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), + senderID: myPeerIDData, recipientID: Data(hexString: peerID), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, @@ -1916,7 +1923,7 @@ final class BLEService: NSObject { let encrypted = try noiseService.encrypt(payload, for: peerID) let packet = BitchatPacket( type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), + senderID: myPeerIDData, recipientID: Data(hexString: peerID), timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, @@ -1982,6 +1989,28 @@ final class BLEService: NSObject { self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers) } } + + // Debounced publish to coalesce rapid changes + private var lastPeerPublishAt: Date = .distantPast + private var peerPublishPending: Bool = false + private let peerPublishMinInterval: TimeInterval = 0.1 + private func requestPeerDataPublish() { + let now = Date() + let elapsed = now.timeIntervalSince(lastPeerPublishAt) + if elapsed >= peerPublishMinInterval { + lastPeerPublishAt = now + publishFullPeerData() + } else if !peerPublishPending { + peerPublishPending = true + let delay = peerPublishMinInterval - elapsed + messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self else { return } + self.lastPeerPublishAt = Date() + self.peerPublishPending = false + self.publishFullPeerData() + } + } + } // MARK: - Consolidated Maintenance @@ -2095,7 +2124,7 @@ final class BLEService: NSObject { self.delegate?.didDisconnectFromPeer(peerID) } // Publish snapshots so UnifiedPeerService updates connection/reachability icons - self.publishFullPeerData() + self.requestPeerDataPublish() self.delegate?.didUpdatePeerList(currentPeerIDs) } } @@ -2477,7 +2506,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph if let peerID = peerID { self.notifyPeerDisconnectedDebounced(peerID) } - self.publishFullPeerData() + self.requestPeerDataPublish() self.delegate?.didUpdatePeerList(currentPeerIDs) } } @@ -2878,7 +2907,7 @@ extension BLEService: CBPeripheralManagerDelegate { self.notifyPeerDisconnectedDebounced(peerID) // Publish snapshots so UnifiedPeerService can refresh icons promptly - self.publishFullPeerData() + self.requestPeerDataPublish() self.delegate?.didUpdatePeerList(currentPeerIDs) } } diff --git a/bitchat/Utils/MessageDeduplicator.swift b/bitchat/Utils/MessageDeduplicator.swift index 6c067466..4fefdfc6 100644 --- a/bitchat/Utils/MessageDeduplicator.swift +++ b/bitchat/Utils/MessageDeduplicator.swift @@ -9,6 +9,7 @@ final class MessageDeduplicator { } private var entries: [Entry] = [] + private var head: Int = 0 private var lookup = Set() private let lock = NSLock() private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes @@ -28,10 +29,18 @@ final class MessageDeduplicator { entries.append(Entry(messageID: messageID, timestamp: Date())) lookup.insert(messageID) - if entries.count > maxCount { - let toRemove = entries.prefix(100) - toRemove.forEach { lookup.remove($0.messageID) } - entries.removeFirst(100) + // Soft-cap and advance head by a chunk to avoid O(n) shifting + if (entries.count - head) > maxCount { + let removeCount = min(100, entries.count - head) + for i in head..<(head + removeCount) { + lookup.remove(entries[i].messageID) + } + head += removeCount + // Periodically compact to reclaim memory + if head > entries.count / 2 { + entries.removeFirst(head) + head = 0 + } } return false @@ -61,6 +70,7 @@ final class MessageDeduplicator { defer { lock.unlock() } entries.removeAll() + head = 0 lookup.removeAll() } @@ -78,9 +88,13 @@ final class MessageDeduplicator { private func cleanupOldEntries() { let cutoff = Date().addingTimeInterval(-maxAge) - while let first = entries.first, first.timestamp < cutoff { - lookup.remove(first.messageID) - entries.removeFirst() + while head < entries.count, entries[head].timestamp < cutoff { + lookup.remove(entries[head].messageID) + head += 1 + } + if head > 0 && head > entries.count / 2 { + entries.removeFirst(head) + head = 0 } } } diff --git a/bitchat/Utils/SecureLogger.swift b/bitchat/Utils/SecureLogger.swift index befc1421..acce8b74 100644 --- a/bitchat/Utils/SecureLogger.swift +++ b/bitchat/Utils/SecureLogger.swift @@ -140,11 +140,11 @@ class SecureLogger { } /// Log general messages with automatic sensitive data filtering - static func log(_ message: String, category: OSLog = noise, level: LogLevel = .debug, + static func log(_ message: @autoclosure () -> String, category: OSLog = noise, level: LogLevel = .debug, file: String = #file, line: Int = #line, function: String = #function) { guard shouldLog(level) else { return } let location = formatLocation(file: file, line: line, function: function) - let sanitized = sanitize("\(location) \(message)") + let sanitized = sanitize("\(location) \(message())") #if DEBUG os_log("%{public}@", log: category, type: level.osLogType, sanitized) @@ -157,10 +157,10 @@ class SecureLogger { } /// Log errors with context - static func logError(_ error: Error, context: String, category: OSLog = noise, + static func logError(_ error: Error, context: @autoclosure () -> String, category: OSLog = noise, file: String = #file, line: Int = #line, function: String = #function) { let location = formatLocation(file: file, line: line, function: function) - let sanitized = sanitize(context) + let sanitized = sanitize(context()) let errorDesc = sanitize(error.localizedDescription) #if DEBUG