From e868e3bbfb28fafba11a1e604c9991a3db8c0266 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 11 Oct 2025 19:49:40 +0200 Subject: [PATCH] Fix BLE queue usage and tighten actor isolation --- bitchat/BitchatApp.swift | 39 +-- bitchat/Protocols/BitchatProtocol.swift | 2 + bitchat/Services/BLEService.swift | 386 ++++++++++++------------ bitchat/Services/NostrTransport.swift | 7 +- bitchat/ViewModels/ChatViewModel.swift | 8 +- bitchat/Views/ContentView.swift | 130 ++++---- 6 files changed, 295 insertions(+), 277 deletions(-) diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 9c9becfb..d61f02c0 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -49,9 +49,10 @@ struct BitchatApp: App { // Inject live Noise service into VerificationService to avoid creating new BLE instances VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) // Prewarm Nostr identity and QR to make first VERIFY sheet fast + let nickname = chatViewModel.nickname DispatchQueue.global(qos: .utility).async { let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub - _ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub) + _ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub) } #if os(iOS) appDelegate.chatViewModel = chatViewModel @@ -237,29 +238,29 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { let identifier = notification.request.identifier let userInfo = notification.request.content.userInfo - // Check if this is a private message notification - if identifier.hasPrefix("private-") { - // Get peer ID from userInfo - if let peerID = userInfo["peerID"] as? String { - // Don't show notification if the private chat is already open - if chatViewModel?.selectedPrivateChatPeer == peerID { - completionHandler([]) - return - } + Task { @MainActor [weak self] in + guard let self = self else { + completionHandler([.banner, .sound]) + return } - } - // Suppress geohash activity notification if we're already in that geohash channel - if identifier.hasPrefix("geo-activity-"), - let deep = userInfo["deeplink"] as? String, - let gh = deep.components(separatedBy: "/").last { - if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh { + // Check if this is a private message notification and chat already open + if identifier.hasPrefix("private-"), + let peerID = userInfo["peerID"] as? String, + self.chatViewModel?.selectedPrivateChatPeer == peerID { completionHandler([]) return } + // Suppress geohash activity notification if we're already in that geohash channel + if identifier.hasPrefix("geo-activity-"), + let deep = userInfo["deeplink"] as? String, + let gh = deep.components(separatedBy: "/").last, + case .location(let ch) = LocationChannelManager.shared.selectedChannel, + ch.geohash == gh { + completionHandler([]) + return + } + completionHandler([.banner, .sound]) } - - // Show notification in all other cases - completionHandler([.banner, .sound]) } } diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index d7585a97..dfb79dfe 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -160,6 +160,7 @@ enum DeliveryStatus: Codable, Equatable, Hashable { // MARK: - Delegate Protocol +@MainActor protocol BitchatDelegate: AnyObject { func didReceiveMessage(_ message: BitchatMessage) func didConnectToPeer(_ peerID: PeerID) @@ -180,6 +181,7 @@ protocol BitchatDelegate: AnyObject { } // Provide default implementation to make it effectively optional +@MainActor extension BitchatDelegate { func isFavorite(fingerprint: String) -> Bool { return false diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index bc9e8130..60342cb6 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -406,23 +406,35 @@ final class BLEService: NSObject { // MARK: Lifecycle func startServices() { - // Start BLE services if not already running - if centralManager?.state == .poweredOn { - centralManager?.scanForPeripherals( - withServices: [BLEService.serviceUUID], - options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] - ) + performOnBLEQueue { service in + // Start BLE services if not already running + if service.centralManager?.state == .poweredOn { + service.centralManager?.scanForPeripherals( + withServices: [BLEService.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + } } - - // Send initial announce after services are ready - // Use longer delay to avoid conflicts with other announces + + // Send initial announce after services are ready. messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) } } func stopServices() { - // Send leave message synchronously to ensure delivery + if DispatchQueue.getSpecific(key: bleQueueKey) != nil { + stopServicesOnBLEQueue() + } else { + bleQueue.sync { + self.stopServicesOnBLEQueue() + } + } + } + + private func stopServicesOnBLEQueue() { + assertOnBLEQueue() + // Send a final leave message to connected peers before shutting down. let leavePacket = BitchatPacket( type: MessageType.leave.rawValue, senderID: myPeerIDData, @@ -432,43 +444,45 @@ final class BLEService: NSObject { signature: nil, ttl: messageTTL ) - - // Send immediately to all connected peers + if let data = leavePacket.toBinaryData(padding: false) { - // Send to peripherals we're connected to as central for state in peripherals.values where state.isConnected { if let characteristic = state.characteristic { writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic) } } - - // Send to centrals subscribed to us as peripheral - if subscribedCentrals.count > 0 && characteristic != nil { - peripheralManager?.updateValue(data, for: characteristic!, onSubscribedCentrals: nil) + if !subscribedCentrals.isEmpty, let characteristic = characteristic { + _ = peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) } } - - // Give leave message a moment to send - Thread.sleep(forTimeInterval: TransportConfig.bleThreadSleepWriteShortDelaySeconds) - - // Clear pending notifications + + let delay = TransportConfig.bleThreadSleepWriteShortDelaySeconds + if delay > 0 { + Thread.sleep(forTimeInterval: delay) + } + collectionsQueue.sync(flags: .barrier) { pendingNotifications.removeAll() + pendingPeripheralWrites.removeAll() + pendingDirectedRelays.removeAll() } - - // Stop timer + maintenanceTimer?.cancel() maintenanceTimer = nil scanDutyTimer?.cancel() scanDutyTimer = nil - + centralManager?.stopScan() peripheralManager?.stopAdvertising() - - // Disconnect all peripherals + for state in peripherals.values { centralManager?.cancelPeripheralConnection(state.peripheral) } + + peripherals.removeAll() + peerToPeripheralUUID.removeAll() + subscribedCentrals.removeAll() + centralToPeerID.removeAll() } func emergencyDisconnectAll() { @@ -483,12 +497,6 @@ final class BLEService: NSObject { // Clear processed messages messageDeduplicator.reset() - - // Clear peripheral references - peripherals.removeAll() - peerToPeripheralUUID.removeAll() - subscribedCentrals.removeAll() - centralToPeerID.removeAll() } // MARK: Connectivity and peers @@ -702,8 +710,9 @@ extension BLEService: GossipSyncManager.Delegate { extension BLEService: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { // Notify delegate about state change on main thread - Task { @MainActor in - self.delegate?.didUpdateBluetoothState(central.state) + let state = central.state + notifyUI { service in + service.delegate?.didUpdateBluetoothState(state) } if central.state == .poweredOn { @@ -933,17 +942,13 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph bleQueue.async { [weak self] in self?.tryConnectFromQueue() } // Notify delegate about disconnection on main thread (direct link dropped) - notifyUI { [weak self] in - guard let self = self else { return } - - // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } - + notifyUI { service in + let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs } if let peerID { - self.notifyPeerDisconnectedDebounced(peerID) + service.notifyPeerDisconnectedDebounced(peerID) } - self.requestPeerDataPublish() - self.delegate?.didUpdatePeerList(currentPeerIDs) + service.requestPeerDataPublish() + service.delegate?.didUpdatePeerList(currentPeerIDs) } } @@ -1354,16 +1359,11 @@ extension BLEService: CBPeripheralManagerDelegate { centralToPeerID.removeValue(forKey: centralUUID) // Update UI immediately - notifyUI { [weak self] in - guard let self = self else { return } - - // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } - - self.notifyPeerDisconnectedDebounced(peerID) - // Publish snapshots so UnifiedPeerService can refresh icons promptly - self.requestPeerDataPublish() - self.delegate?.didUpdatePeerList(currentPeerIDs) + notifyUI { service in + let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs } + service.notifyPeerDisconnectedDebounced(peerID) + service.requestPeerDataPublish() + service.delegate?.didUpdatePeerList(currentPeerIDs) } } } @@ -1517,13 +1517,29 @@ extension BLEService { extension BLEService { - /// Notify UI on the MainActor to satisfy Swift concurrency isolation - private func notifyUI(_ block: @escaping () -> Void) { - // Always hop onto the MainActor so calls to @MainActor delegates are safe - Task { @MainActor in - block() + /// Hop to the MainActor and hand `self` to the caller for UI delegate updates. + private func notifyUI(_ block: @MainActor @escaping (BLEService) -> Void) { + Task { @MainActor [weak self] in + guard let self = self else { return } + block(self) } } + + /// Ensure work that touches CoreBluetooth objects executes on `bleQueue` + private func performOnBLEQueue(_ work: @escaping (BLEService) -> Void) { + if DispatchQueue.getSpecific(key: bleQueueKey) != nil { + work(self) + } else { + bleQueue.async { [weak self] in + guard let self = self else { return } + work(self) + } + } + } + + private func assertOnBLEQueue(file: StaticString = #file, line: UInt = #line) { + assert(DispatchQueue.getSpecific(key: bleQueueKey) != nil, "BLE queue required", file: file, line: line) + } private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) { service.onPeerAuthenticated = { [weak self] peerID, fingerprint in @@ -1634,42 +1650,13 @@ extension BLEService { } private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) { - // BLE operations run on bleQueue; keep queue affinity - bleQueue.async { [weak self] in - guard let self = self else { return } - let uuid = peripheral.identifier.uuidString - if peripheral.canSendWriteWithoutResponse { - peripheral.writeValue(data, for: characteristic, type: .withoutResponse) - } else { - self.collectionsQueue.async(flags: .barrier) { - var queue = self.pendingPeripheralWrites[uuid] ?? [] - let capBytes = TransportConfig.blePendingWriteBufferCapBytes - let newSize = data.count - // If single chunk exceeds cap, drop it immediately - if newSize > capBytes { - SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session) - } else { - // Append and trim from the front to respect cap - var total = queue.reduce(0) { $0 + $1.count } - queue.append(data) - total += newSize - if total > capBytes { - var removedBytes = 0 - while total > capBytes && !queue.isEmpty { - let removed = queue.removeFirst() - removedBytes += removed.count - total -= removed.count - } - SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session) - } - self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue - } - } - } + performOnBLEQueue { service in + service.writeOrEnqueueOnBLEQueue(data, to: peripheral, characteristic: characteristic) } } private func drainPendingWrites(for peripheral: CBPeripheral) { + assertOnBLEQueue() let uuid = peripheral.identifier.uuidString bleQueue.async { [weak self] in guard let self = self else { return } @@ -1701,6 +1688,38 @@ extension BLEService { } } } + + private func writeOrEnqueueOnBLEQueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) { + let uuid = peripheral.identifier.uuidString + if peripheral.canSendWriteWithoutResponse { + peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + return + } + + collectionsQueue.async(flags: .barrier) { + var queue = self.pendingPeripheralWrites[uuid] ?? [] + let capBytes = TransportConfig.blePendingWriteBufferCapBytes + let newSize = data.count + if newSize > capBytes { + SecureLogger.warning("⚠️ Dropping oversized write chunk (\(newSize)B) for peripheral \(uuid)", category: .session) + return + } + + var total = queue.reduce(0) { $0 + $1.count } + queue.append(data) + total += newSize + if total > capBytes { + var removedBytes = 0 + while total > capBytes && !queue.isEmpty { + let removed = queue.removeFirst() + removedBytes += removed.count + total -= removed.count + } + SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(removedBytes)B to \(total)B", category: .session) + } + self.pendingPeripheralWrites[uuid] = queue.isEmpty ? nil : queue + } + } // MARK: Application State Handlers (iOS) @@ -1784,8 +1803,8 @@ extension BLEService { } // Notify delegate that message was sent - notifyUI { [weak self] in - self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) + notifyUI { service in + service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) } } catch { SecureLogger.error("Failed to encrypt message: \(error)") @@ -1805,8 +1824,8 @@ extension BLEService { initiateNoiseHandshake(with: recipientID) // Notify delegate that message is pending - notifyUI { [weak self] in - self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending) + notifyUI { service in + service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sending) } } } @@ -1882,8 +1901,8 @@ extension BLEService { broadcastPacket(packet) // Notify delegate that message was sent - notifyUI { [weak self] in - self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) + notifyUI { service in + service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .sent) } SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session) @@ -1891,8 +1910,8 @@ extension BLEService { SecureLogger.error("Failed to send pending message after handshake: \(error)") // Notify delegate of failure - notifyUI { [weak self] in - self?.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed")) + notifyUI { service in + service.delegate?.didUpdateMessageDeliveryStatus(messageID, status: .failed(reason: "Encryption failed")) } } } @@ -1907,11 +1926,13 @@ extension BLEService { SecureLogger.error("❌ Failed to convert packet to binary data", category: .session) return } - if packet.type == MessageType.noiseEncrypted.rawValue { - sendEncrypted(packet, data: data, pad: padForBLE) - return + performOnBLEQueue { service in + if packet.type == MessageType.noiseEncrypted.rawValue { + service.sendEncrypted(packet, data: data, pad: padForBLE) + } else { + service.sendGenericBroadcast(packet, data: data, pad: padForBLE) + } } - sendGenericBroadcast(packet, data: data, pad: padForBLE) } // MARK: Broadcast helpers (single responsibility) @@ -2550,21 +2571,15 @@ extension BLEService { } // Notify UI on main thread - notifyUI { [weak self] in - guard let self = self else { return } - - // Get current peer list (after addition) - let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } - - // Only notify of connection for new or reconnected peers when it is a direct announce - if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) { - self.delegate?.didConnectToPeer(peerID) - // Schedule initial unicast sync to this peer - self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0) + let isDirectAnnounce = (packet.ttl == messageTTL) && (isNewPeer || isReconnectedPeer) + notifyUI { service in + let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs } + if isDirectAnnounce { + service.delegate?.didConnectToPeer(peerID) + service.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0) } - - self.requestPeerDataPublish() - self.delegate?.didUpdatePeerList(currentPeerIDs) + service.requestPeerDataPublish() + service.delegate?.didUpdatePeerList(currentPeerIDs) } // Track for sync (include our own and others' announces) @@ -2636,17 +2651,26 @@ extension BLEService { if peerID == myPeerID { accepted = true senderNickname = myNickname - } - 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.peerID != peerID } || (myNickname == info.nickname) - if hasCollision { - senderNickname += "#" + String(peerID.id.prefix(4)) - } } else { + let selfNickname = myNickname + let peerLookup = collectionsQueue.sync { () -> (PeerInfo?, Bool) in + guard let info = peers[peerID] else { return (nil, false) } + let collision = peers.values.contains { + $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID + } + return (info, collision) + } + if let info = peerLookup.0, info.isVerifiedNickname { + accepted = true + senderNickname = info.nickname + let hasCollision = peerLookup.1 || (selfNickname == info.nickname) + if hasCollision { + senderNickname += "#" + String(peerID.id.prefix(4)) + } + } + } + + if !accepted { // 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) @@ -2704,8 +2728,8 @@ extension BLEService { SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts) + notifyUI { service in + service.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts) } } @@ -2770,28 +2794,28 @@ extension BLEService { switch NoisePayloadType(rawValue: payloadType) { case .privateMessage: let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts) + notifyUI { service in + service.delegate?.didReceiveNoisePayload(from: peerID, type: .privateMessage, payload: Data(payloadData), timestamp: ts) } case .delivered: let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts) + notifyUI { service in + service.delegate?.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data(payloadData), timestamp: ts) } case .readReceipt: let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts) + notifyUI { service in + service.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts) } case .verifyChallenge: let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts) + notifyUI { service in + service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts) } case .verifyResponse: let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts) + notifyUI { service in + service.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts) } default: SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)") @@ -2816,14 +2840,10 @@ extension BLEService { // Remove any stored announcement for sync purposes gossipSyncManager?.removeAnnouncementForPeer(peerID) // Send on main thread - notifyUI { [weak self] in - guard let self = self else { return } - - // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } - - self.delegate?.didDisconnectFromPeer(peerID) - self.delegate?.didUpdatePeerList(currentPeerIDs) + notifyUI { service in + let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs } + service.delegate?.didDisconnectFromPeer(peerID) + service.delegate?.didUpdatePeerList(currentPeerIDs) } } @@ -2841,62 +2861,48 @@ extension BLEService { } private func sendAnnounce(forceSend: Bool = false) { - // Throttle announces to prevent flooding - let now = Date() - let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent) - - // Even forced sends should respect a minimum interval to avoid overwhelming BLE - let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval - - if timeSinceLastAnnounce < minInterval { - // Skipping announce (rate limited) - return + performOnBLEQueue { service in + service.sendAnnounceOnBLEQueue(forceSend: forceSend) } + } + + private func sendAnnounceOnBLEQueue(forceSend: Bool) { + let now = Date() + let elapsed = now.timeIntervalSince(lastAnnounceSent) + let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval + guard elapsed >= minInterval else { return } lastAnnounceSent = now - - // Reduced logging - only log errors, not every announce - - // Create announce payload with both noise and signing public keys - let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification - let signingPub = noiseService.getSigningPublicKeyData() // For signature verification - + + let noisePub = noiseService.getStaticPublicKeyData() + let signingPub = noiseService.getSigningPublicKeyData() + let announcement = AnnouncementPacket( nickname: myNickname, noisePublicKey: noisePub, signingPublicKey: signingPub ) - + guard let payload = announcement.encode() else { SecureLogger.error("❌ Failed to encode announce packet", category: .session) return } - - // Create packet with signature using the noise private key - let packet = BitchatPacket( + + let unsigned = BitchatPacket( type: MessageType.announce.rawValue, senderID: myPeerIDData, recipientID: nil, timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: payload, - signature: nil, // Will be set by signPacket below + signature: nil, ttl: messageTTL ) - - // Sign the packet using the noise private key - guard let signedPacket = noiseService.signPacket(packet) else { + + guard let signedPacket = noiseService.signPacket(unsigned) else { SecureLogger.error("❌ Failed to sign announce packet", category: .security) return } - - // Call directly if on messageQueue, otherwise dispatch - if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - broadcastPacket(signedPacket) - } else { - messageQueue.async { [weak self] in - self?.broadcastPacket(signedPacket) - } - } - // Ensure our own announce is included in sync state + + broadcastPacket(signedPacket) gossipSyncManager?.onPublicPacketSeen(signedPacket) } @@ -2938,6 +2944,7 @@ extension BLEService { } // Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window + @MainActor private func notifyPeerDisconnectedDebounced(_ peerID: PeerID) { let now = Date() let last = recentDisconnectNotifies[peerID] @@ -3082,18 +3089,13 @@ extension BLEService { // Update UI if there were direct disconnections or offline removals if !disconnectedPeers.isEmpty || removedOfflineCount > 0 { - notifyUI { [weak self] in - guard let self else { return } - - // Get current peer list (after removal) - let currentPeerIDs = self.collectionsQueue.sync { self.currentPeerIDs } - + notifyUI { service in + let currentPeerIDs = service.collectionsQueue.sync { service.currentPeerIDs } for peerID in disconnectedPeers { - self.delegate?.didDisconnectFromPeer(PeerID(str: peerID)) + service.delegate?.didDisconnectFromPeer(PeerID(str: peerID)) } - // Publish snapshots so UnifiedPeerService updates connection/reachability icons - self.requestPeerDataPublish() - self.delegate?.didUpdatePeerList(currentPeerIDs) + service.requestPeerDataPublish() + service.delegate?.didUpdatePeerList(currentPeerIDs) } } } diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index a7ab3375..d9287cb4 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -50,10 +50,11 @@ final class NostrTransport: Transport { // Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation private static var cachedNoiseService: NoiseEncryptionService? + private static let noiseServiceLock = NSLock() func getNoiseService() -> NoiseEncryptionService { - if let noiseService = Self.cachedNoiseService { - return noiseService - } + Self.noiseServiceLock.lock() + defer { Self.noiseServiceLock.unlock() } + if let noiseService = Self.cachedNoiseService { return noiseService } let noiseService = NoiseEncryptionService(keychain: keychain) Self.cachedNoiseService = noiseService return noiseService diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 35fca3b2..d308f342 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -91,6 +91,7 @@ import UIKit /// Manages the application state and business logic for BitChat. /// Acts as the primary coordinator between UI components and backend services, /// implementing the BitchatDelegate protocol to handle network events. +@MainActor final class ChatViewModel: ObservableObject, BitchatDelegate { // Precompiled regexes and detectors reused across formatting private enum Regexes { @@ -229,14 +230,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects @Published var nickname: String = "" { didSet { - // Trim whitespace whenever nickname is set let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed != nickname { nickname = trimmed + return } - // Update mesh service nickname if it's initialized - if meshService.myPeerID != "" { - meshService.setNickname(nickname) + if meshService.myPeerID != "", meshService.myNickname != trimmed { + meshService.setNickname(trimmed) } } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 3f0abadc..a0bf074a 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -19,7 +19,12 @@ import UIKit // MARK: - Main Content View +@MainActor struct ContentView: View { + private struct ScrollRequest: Equatable { + let id = UUID() + let target: String + } // MARK: - Properties @EnvironmentObject var viewModel: ChatViewModel @@ -46,7 +51,8 @@ struct ContentView: View { @State private var isAtBottomPublic: Bool = true @State private var isAtBottomPrivate: Bool = true @State private var lastScrollTime: Date = .distantPast - @State private var scrollThrottleTimer: Timer? + @State private var scrollThrottleTask: Task? + @State private var pendingScrollRequest: ScrollRequest? @State private var autocompleteDebounceTimer: Timer? @State private var showLocationChannelsSheet = false @State private var showVerifySheet = false @@ -79,6 +85,30 @@ struct ContentView: View { private var headerLineLimit: Int? { dynamicTypeSize.isAccessibilitySize ? 2 : 1 } + + private func enqueueScroll(target: String) { + pendingScrollRequest = ScrollRequest(target: target) + } + + private func scheduleScroll(target: String, delay: TimeInterval) { + scrollThrottleTask?.cancel() + scrollThrottleTask = nil + if delay <= 0 { + lastScrollTime = Date() + enqueueScroll(target: target) + scrollThrottleTask = nil + return + } + let nanoseconds = UInt64(max(delay, 0) * 1_000_000_000) + scrollThrottleTask = Task { [target] in + try? await Task.sleep(nanoseconds: nanoseconds) + await MainActor.run { + lastScrollTime = Date() + enqueueScroll(target: target) + scrollThrottleTask = nil + } + } + } // MARK: - Body @@ -296,8 +326,8 @@ struct ContentView: View { Text(viewModel.bluetoothAlertMessage) } .onDisappear { - // Clean up timers - scrollThrottleTimer?.invalidate() + scrollThrottleTask?.cancel() + scrollThrottleTask = nil autocompleteDebounceTimer?.invalidate() } } @@ -422,6 +452,11 @@ struct ContentView: View { .padding(.vertical, 4) } .background(backgroundColor) + .onChange(of: pendingScrollRequest) { request in + guard let request else { return } + proxy.scrollTo(request.target, anchor: .bottom) + pendingScrollRequest = nil + } .onOpenURL { url in guard url.scheme == "bitchat", url.host == "user" else { return } let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) @@ -543,44 +578,27 @@ struct ContentView: View { let lastMsg = viewModel.messages.last! let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#") if !isFromSelf { - // Only autoscroll when user is at/near bottom guard isAtBottom.wrappedValue else { return } } else { - // Ensure we consider ourselves at bottom for subsequent messages isAtBottom.wrappedValue = true } - // Throttle scroll animations to prevent excessive UI updates let now = Date() - if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds { - // Immediate scroll if enough time has passed - lastScrollTime = now - let contextKey: String = { - switch locationManager.selectedChannel { - case .mesh: return "mesh" - case .location(let ch): return "geo:\(ch.geohash)" - } - }() - let count = windowCountPublic - let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } - DispatchQueue.main.async { - if let target = target { proxy.scrollTo(target, anchor: .bottom) } + let contextKey: String = { + switch locationManager.selectedChannel { + case .mesh: return "mesh" + case .location(let ch): return "geo:\(ch.geohash)" } - } else { - // Schedule a delayed scroll - scrollThrottleTimer?.invalidate() - scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in - lastScrollTime = Date() - let contextKey: String = { - switch locationManager.selectedChannel { - case .mesh: return "mesh" - case .location(let ch): return "geo:\(ch.geohash)" - } - }() - let count = windowCountPublic - let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } - DispatchQueue.main.async { - if let target = target { proxy.scrollTo(target, anchor: .bottom) } - } + }() + let count = windowCountPublic + if let target = viewModel.messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }) { + let elapsed = now.timeIntervalSince(lastScrollTime) + if elapsed > TransportConfig.uiScrollThrottleSeconds { + scrollThrottleTask?.cancel() + lastScrollTime = now + enqueueScroll(target: target) + } else { + let remaining = TransportConfig.uiScrollThrottleSeconds - elapsed + scheduleScroll(target: target, delay: remaining) } } } @@ -598,26 +616,18 @@ struct ContentView: View { } else { isAtBottom.wrappedValue = true } - // Same throttling for private chats let now = Date() - if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds { - lastScrollTime = now - let contextKey = "dm:\(peerID)" - let count = windowCountPrivate[peerID] ?? 300 - let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } - DispatchQueue.main.async { - if let target = target { proxy.scrollTo(target, anchor: .bottom) } - } - } else { - scrollThrottleTimer?.invalidate() - scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in - lastScrollTime = Date() - let contextKey = "dm:\(peerID)" - let count = windowCountPrivate[peerID] ?? 300 - let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } - DispatchQueue.main.async { - if let target = target { proxy.scrollTo(target, anchor: .bottom) } - } + let contextKey = "dm:\(peerID)" + let count = windowCountPrivate[peerID] ?? 300 + if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }) { + let elapsed = now.timeIntervalSince(lastScrollTime) + if elapsed > TransportConfig.uiScrollThrottleSeconds { + scrollThrottleTask?.cancel() + lastScrollTime = now + enqueueScroll(target: target) + } else { + let remaining = TransportConfig.uiScrollThrottleSeconds - elapsed + scheduleScroll(target: target, delay: remaining) } } } @@ -695,9 +705,9 @@ struct ContentView: View { } .buttonStyle(.plain) .background(Color.gray.opacity(0.1)) - } } - .background(backgroundColor) + } + .background(backgroundColor) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1) @@ -791,9 +801,11 @@ struct ContentView: View { // Debounce autocomplete updates to reduce calls during rapid typing autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in - // Get cursor position (approximate - end of text for now) - let cursorPosition = newValue.count - viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) + Task { @MainActor in + // Get cursor position (approximate - end of text for now) + let cursorPosition = newValue.count + viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) + } } // Check for command autocomplete (instant, no debounce needed)